重命名前端目录
This commit is contained in:
@@ -1 +0,0 @@
|
||||
VITE_API_TARGET=http://127.0.0.1:3000
|
||||
@@ -1 +0,0 @@
|
||||
VITE_API_TARGET=http://127.0.0.1:3000
|
||||
@@ -1,25 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.ace-tool/
|
||||
@@ -1,4 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
src/auto-imports.d.ts
|
||||
src/components.d.ts
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
# 快手轻量前端
|
||||
|
||||
这是快手自动发货拆分后的 Vue 3 前端,只保留快手领取和运营后台相关入口。
|
||||
|
||||
## 功能范围
|
||||
|
||||
- `/claim/:token` 快手 Cloud 领取页
|
||||
- `/admin` 运营后台
|
||||
- 91 卡券订单补全与任务生成
|
||||
- 快手小店核销配置
|
||||
- Cloudtentacles 履约配置与调试
|
||||
- 内部通知和定时任务配置
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
开发环境默认通过 `VITE_API_TARGET` 代理后端:
|
||||
|
||||
```bash
|
||||
VITE_API_TARGET=http://localhost:3000 npm run dev
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run build
|
||||
```
|
||||
@@ -1,34 +0,0 @@
|
||||
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 }],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -2,12 +2,11 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>自动兑换前端</title>
|
||||
<title>order-site react</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+1139
-2662
File diff suppressed because it is too large
Load Diff
+20
-29
@@ -1,41 +1,32 @@
|
||||
{
|
||||
"name": "order-site-rewrite",
|
||||
"name": "order-site-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"axios": "^1.9.0",
|
||||
"element-plus": "^2.10.2",
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"antd": "^6.1.1",
|
||||
"axios": "^1.13.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"qrcode": "^1.5.4",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^4.5.1"
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-router": "^7.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vue/tsconfig": "^0.9.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.99.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.0.0",
|
||||
"vite": "^8.0.7",
|
||||
"vue-tsc": "^3.2.5"
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 245 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 329 KiB |
@@ -0,0 +1,90 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Spin } from 'antd'
|
||||
import { HashRouter, Navigate, Outlet, Route, Routes, useLocation } from 'react-router'
|
||||
|
||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||
import AdminLayout from '@/layouts/AdminLayout'
|
||||
|
||||
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
|
||||
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
|
||||
const AdminCloudtentaclesRecordsPage = lazy(
|
||||
() => import('@/pages/admin/AdminCloudtentaclesRecordsPage'),
|
||||
)
|
||||
const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage'))
|
||||
const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage'))
|
||||
const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
|
||||
const AdminPlatformFulfillmentPage = lazy(
|
||||
() => import('@/pages/admin/platform/AdminPlatformFulfillmentPage'),
|
||||
)
|
||||
const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPlatformShopsPage'))
|
||||
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
|
||||
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
|
||||
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
|
||||
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
|
||||
function RequireAdmin() {
|
||||
const location = useLocation()
|
||||
|
||||
if (!hasAdminSession()) {
|
||||
return <Navigate to="/admin/login" replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
function RequireRole({ roles }: { roles: Array<'admin' | 'operator' | 'support'> }) {
|
||||
if (!roles.includes(getAdminRole())) {
|
||||
return <Navigate to="/admin/dashboard" replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
function LoginRoute() {
|
||||
if (hasAdminSession()) {
|
||||
return <Navigate to="/admin/dashboard" replace />
|
||||
}
|
||||
|
||||
return <AdminLoginPage />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="/admin/login" element={<LoginRoute />} />
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<AdminDashboardPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
|
||||
<Route path="tasks" element={<AdminTasksPage />} />
|
||||
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
|
||||
<Route path="cloudtentacles-records" element={<AdminCloudtentaclesRecordsPage />} />
|
||||
<Route element={<RequireRole roles={['admin']} />}>
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
|
||||
<Route path="platform-fulfillment" element={<AdminPlatformFulfillmentPage />} />
|
||||
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/claim/:token" element={<ClaimPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</HashRouter>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteLoading() {
|
||||
return (
|
||||
<div className="route-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ElConfigProvider } from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElConfigProvider :locale="zhCn">
|
||||
<RouterView />
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
Vendored
-78
@@ -1,78 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const computed: typeof import('vue').computed
|
||||
const createApp: typeof import('vue').createApp
|
||||
const customRef: typeof import('vue').customRef
|
||||
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||
const defineComponent: typeof import('vue').defineComponent
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const h: typeof import('vue').h
|
||||
const inject: typeof import('vue').inject
|
||||
const isProxy: typeof import('vue').isProxy
|
||||
const isReactive: typeof import('vue').isReactive
|
||||
const isReadonly: typeof import('vue').isReadonly
|
||||
const isRef: typeof import('vue').isRef
|
||||
const isShallow: typeof import('vue').isShallow
|
||||
const markRaw: typeof import('vue').markRaw
|
||||
const nextTick: typeof import('vue').nextTick
|
||||
const onActivated: typeof import('vue').onActivated
|
||||
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
|
||||
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
|
||||
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||
const onDeactivated: typeof import('vue').onDeactivated
|
||||
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||
const onMounted: typeof import('vue').onMounted
|
||||
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const provide: typeof import('vue').provide
|
||||
const reactive: typeof import('vue').reactive
|
||||
const readonly: typeof import('vue').readonly
|
||||
const ref: typeof import('vue').ref
|
||||
const resolveComponent: typeof import('vue').resolveComponent
|
||||
const shallowReactive: typeof import('vue').shallowReactive
|
||||
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||
const shallowRef: typeof import('vue').shallowRef
|
||||
const toRaw: typeof import('vue').toRaw
|
||||
const toRef: typeof import('vue').toRef
|
||||
const toRefs: typeof import('vue').toRefs
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const unref: typeof import('vue').unref
|
||||
const useAttrs: typeof import('vue').useAttrs
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssVars: typeof import('vue').useCssVars
|
||||
const useId: typeof import('vue').useId
|
||||
const useLink: typeof import('vue-router').useLink
|
||||
const useModel: typeof import('vue').useModel
|
||||
const useRoute: typeof import('vue-router').useRoute
|
||||
const useRouter: typeof import('vue-router').useRouter
|
||||
const useSlots: typeof import('vue').useSlots
|
||||
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||
const watch: typeof import('vue').watch
|
||||
const watchEffect: typeof import('vue').watchEffect
|
||||
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
Vendored
-61
@@ -1,61 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AdminPageHeader: typeof import('./components/admin/AdminPageHeader.vue')['default']
|
||||
AdminResultCard: typeof import('./components/admin/AdminResultCard.vue')['default']
|
||||
AdminStatusTag: typeof import('./components/admin/AdminStatusTag.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElResult: typeof import('element-plus/es')['ElResult']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.extra" class="header-extra">
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-3xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.header-extra {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="result-card">
|
||||
<span class="result-label">{{ label }}</span>
|
||||
<strong>{{ value }}</strong>
|
||||
<slot />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.result-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.result-card strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.result-card :deep(p) {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -1,167 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { formatStatusWithRaw } from '@/utils/admin-display'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
status: string
|
||||
showRaw?: boolean
|
||||
}>(),
|
||||
{
|
||||
showRaw: true,
|
||||
},
|
||||
)
|
||||
|
||||
type ElTagType = 'success' | 'primary' | 'warning' | 'danger' | 'info'
|
||||
|
||||
function resolveType(status: string): ElTagType {
|
||||
const normalized = String(status || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (
|
||||
[
|
||||
'paid',
|
||||
'link_generated',
|
||||
'redeemed',
|
||||
'available',
|
||||
'delivered',
|
||||
'success',
|
||||
'active',
|
||||
'system_bound',
|
||||
'binding_completed',
|
||||
'completed',
|
||||
].includes(normalized)
|
||||
) {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
'claimed',
|
||||
'role_confirmed',
|
||||
'redeeming',
|
||||
'reserved',
|
||||
'processing',
|
||||
'admin',
|
||||
'support',
|
||||
'waiting_user_claim',
|
||||
'link_opened',
|
||||
'binding_confirmed',
|
||||
'binding_in_progress',
|
||||
'user_binding',
|
||||
'pending',
|
||||
'waiting',
|
||||
'waiting_binding',
|
||||
].includes(normalized)
|
||||
) {
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
'retry_pending',
|
||||
'manual_review',
|
||||
'invalid',
|
||||
'expired',
|
||||
'operator',
|
||||
'pending_binding',
|
||||
'pending_config',
|
||||
'binding_exception',
|
||||
'not_started',
|
||||
'consumed',
|
||||
'skipped',
|
||||
'pending_binding_prepare',
|
||||
'dispatched_pending_return',
|
||||
].includes(normalized)
|
||||
) {
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
if (['closed', 'failed', 'unpaid', 'revoked', 'disabled'].includes(normalized)) {
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function resolveLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
|
||||
if (!normalized) return '-'
|
||||
|
||||
const labelMap: Record<string, string> = {
|
||||
created: '已创建',
|
||||
paid: '已支付',
|
||||
unpaid: '未支付',
|
||||
failed: '失败',
|
||||
refunded: '已退款',
|
||||
refunding: '退款中',
|
||||
closed: '已关闭',
|
||||
pending_payment: '待支付',
|
||||
cdk_reserved: '资源已准备',
|
||||
link_generated: '已生成链接',
|
||||
claimed: '已创建会话',
|
||||
role_confirmed: '已确认角色',
|
||||
redeeming: '兑换中',
|
||||
redeemed: '已兑换',
|
||||
retry_pending: '待重试',
|
||||
manual_review: '人工处理',
|
||||
expired: '已过期',
|
||||
available: '可用',
|
||||
reserved: '资源已准备',
|
||||
delivered: '已发放',
|
||||
consumed: '已使用',
|
||||
invalid: '已作废',
|
||||
active: '生效中',
|
||||
admin: '管理员',
|
||||
operator: '普通运营',
|
||||
support: '客服',
|
||||
revoked: '已撤销',
|
||||
disabled: '已停用',
|
||||
success: '成功',
|
||||
skipped: '已跳过',
|
||||
pending: '待触发',
|
||||
waiting: '等待中',
|
||||
processing: '处理中',
|
||||
system_bound: '资源已准备',
|
||||
pending_binding: '待准备资源',
|
||||
resource_ready: '资源已准备',
|
||||
pending_prepare: '待准备资源',
|
||||
pending_config: '待配置',
|
||||
pending_binding_prepare: '待准备资源',
|
||||
not_started: '未开始',
|
||||
waiting_binding: '待客户绑定',
|
||||
dispatched_pending_return: '待退还号码',
|
||||
completed: '已完成',
|
||||
waiting_user_claim: '待客户打开链接',
|
||||
waiting_customer: '待客户绑定',
|
||||
link_opened: '已打开链接',
|
||||
binding_confirmed: '已提交',
|
||||
binding_in_progress: '处理中',
|
||||
binding_completed: '履约完成',
|
||||
binding_exception: '履约异常',
|
||||
customer_confirmed: '已提交',
|
||||
customer_processing: '处理中',
|
||||
customer_completed: '履约完成',
|
||||
customer_exception: '履约异常',
|
||||
resource_exception: '资源异常',
|
||||
user_binding: '客户处理中',
|
||||
}
|
||||
|
||||
if (!props.showRaw) {
|
||||
return labelMap[normalized.toLowerCase()] || normalized
|
||||
}
|
||||
|
||||
return formatStatusWithRaw(normalized, labelMap)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-tag
|
||||
:type="resolveType(props.status)"
|
||||
size="small"
|
||||
:title="props.showRaw ? undefined : props.status"
|
||||
>
|
||||
{{ resolveLabel(props.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
type JsonPreviewProps = {
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export default function JsonPreview({ value }: JsonPreviewProps) {
|
||||
return <pre className="json-preview">{JSON.stringify(value ?? {}, null, 2)}</pre>
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Space, Typography } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string
|
||||
description?: string
|
||||
extra?: ReactNode
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, description, extra }: PageHeaderProps) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<Typography.Title level={2} className="page-title">
|
||||
{title}
|
||||
</Typography.Title>
|
||||
{description ? <p className="page-description">{description}</p> : null}
|
||||
</div>
|
||||
{extra ? <Space wrap>{extra}</Space> : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Tag } from 'antd'
|
||||
|
||||
type StatusTagProps = {
|
||||
value?: string | null
|
||||
kind?: 'task' | 'pay' | 'delivery' | 'resource' | 'customer' | 'role' | 'plain'
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
active: '生效中',
|
||||
admin: '管理员',
|
||||
available: '可用',
|
||||
binding_completed: '履约完成',
|
||||
binding_confirmed: '已提交',
|
||||
binding_exception: '履约异常',
|
||||
binding_in_progress: '处理中',
|
||||
canceled: '已取消',
|
||||
cancelled: '已取消',
|
||||
cdk_reserved: '资源已准备',
|
||||
claimed: '已创建会话',
|
||||
closed: '已关闭',
|
||||
completed: '已完成',
|
||||
consumed: '已核销',
|
||||
created: '已创建',
|
||||
customer_completed: '履约完成',
|
||||
customer_confirmed: '已提交',
|
||||
customer_exception: '履约异常',
|
||||
customer_processing: '处理中',
|
||||
delivered: '已发放',
|
||||
disabled: '已停用',
|
||||
dispatched_pending_return: '待退还号码',
|
||||
expired: '已过期',
|
||||
failed: '失败',
|
||||
invalid: '已作废',
|
||||
link_generated: '已生成链接',
|
||||
link_opened: '已打开链接',
|
||||
manual_review: '人工处理',
|
||||
not_started: '未开始',
|
||||
operator: '普通运营',
|
||||
paid: '已支付',
|
||||
pending: '待触发',
|
||||
pending_binding: '待准备资源',
|
||||
pending_binding_prepare: '待准备资源',
|
||||
pending_claim: '待领取',
|
||||
pending_config: '待配置',
|
||||
pending_payment: '待支付',
|
||||
pending_prepare: '待准备资源',
|
||||
processing: '处理中',
|
||||
redeemed: '已兑换',
|
||||
redeeming: '兑换中',
|
||||
refunded: '已退款',
|
||||
refunding: '退款中',
|
||||
reserved: '资源已准备',
|
||||
resource_exception: '资源异常',
|
||||
resource_ready: '资源已准备',
|
||||
revoked: '已撤销',
|
||||
retry_pending: '待重试',
|
||||
role_confirmed: '已确认角色',
|
||||
skipped: '已跳过',
|
||||
success: '成功',
|
||||
support: '客服',
|
||||
system_bound: '资源已准备',
|
||||
unpaid: '未支付',
|
||||
user_binding: '客户处理中',
|
||||
verified: '已校验',
|
||||
waiting: '等待中',
|
||||
waiting_binding: '待客户绑定',
|
||||
waiting_customer: '待客户绑定',
|
||||
waiting_user_claim: '待客户打开链接',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: 'green',
|
||||
admin: 'purple',
|
||||
available: 'green',
|
||||
binding_completed: 'green',
|
||||
binding_confirmed: 'blue',
|
||||
binding_exception: 'red',
|
||||
binding_in_progress: 'blue',
|
||||
canceled: 'default',
|
||||
cancelled: 'default',
|
||||
cdk_reserved: 'green',
|
||||
claimed: 'blue',
|
||||
closed: 'default',
|
||||
completed: 'green',
|
||||
consumed: 'green',
|
||||
created: 'blue',
|
||||
customer_completed: 'green',
|
||||
customer_confirmed: 'blue',
|
||||
customer_exception: 'red',
|
||||
customer_processing: 'blue',
|
||||
delivered: 'green',
|
||||
disabled: 'red',
|
||||
failed: 'red',
|
||||
invalid: 'orange',
|
||||
link_generated: 'cyan',
|
||||
link_opened: 'blue',
|
||||
manual_review: 'orange',
|
||||
not_started: 'gold',
|
||||
operator: 'gold',
|
||||
paid: 'green',
|
||||
pending: 'gold',
|
||||
pending_binding: 'gold',
|
||||
pending_binding_prepare: 'gold',
|
||||
pending_claim: 'gold',
|
||||
pending_config: 'gold',
|
||||
pending_payment: 'default',
|
||||
pending_prepare: 'gold',
|
||||
processing: 'blue',
|
||||
redeemed: 'green',
|
||||
redeeming: 'blue',
|
||||
refunded: 'purple',
|
||||
refunding: 'purple',
|
||||
reserved: 'green',
|
||||
resource_exception: 'red',
|
||||
resource_ready: 'green',
|
||||
revoked: 'red',
|
||||
retry_pending: 'volcano',
|
||||
role_confirmed: 'blue',
|
||||
skipped: 'gold',
|
||||
success: 'green',
|
||||
support: 'blue',
|
||||
system_bound: 'green',
|
||||
unpaid: 'default',
|
||||
user_binding: 'blue',
|
||||
verified: 'green',
|
||||
waiting: 'blue',
|
||||
waiting_binding: 'gold',
|
||||
waiting_customer: 'gold',
|
||||
waiting_user_claim: 'blue',
|
||||
}
|
||||
|
||||
export default function StatusTag({ value, kind = 'plain' }: StatusTagProps) {
|
||||
const normalized = String(value || '').trim()
|
||||
|
||||
if (!normalized) {
|
||||
return <span className="muted">-</span>
|
||||
}
|
||||
|
||||
const key = normalized.toLowerCase()
|
||||
const label = STATUS_LABELS[key] || normalized
|
||||
const color = STATUS_COLORS[key] || resolveFallbackColor(kind)
|
||||
|
||||
return (
|
||||
<Tag color={color} title={normalized}>
|
||||
{label}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveFallbackColor(kind: StatusTagProps['kind']) {
|
||||
if (kind === 'pay' || kind === 'delivery') return 'blue'
|
||||
if (kind === 'task') return 'geekblue'
|
||||
if (kind === 'resource' || kind === 'customer') return 'cyan'
|
||||
if (kind === 'role') return 'purple'
|
||||
return 'default'
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { isFeedbackDismissed, showConfirm, showError, showSuccess } from '@/lib/feedback'
|
||||
|
||||
export interface RunActionOptions {
|
||||
id: number | string
|
||||
action: () => Promise<unknown>
|
||||
successMessage: string
|
||||
confirmText: string
|
||||
onAfterAction?: () => Promise<unknown> | void
|
||||
}
|
||||
|
||||
export function useAdminAction() {
|
||||
const actionLoadingId = ref<number | string | null>(null)
|
||||
|
||||
async function runAction(options: RunActionOptions) {
|
||||
try {
|
||||
await showConfirm(options.confirmText, '确认操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
} catch (error) {
|
||||
if (isFeedbackDismissed(error)) return
|
||||
throw error
|
||||
}
|
||||
|
||||
actionLoadingId.value = options.id
|
||||
|
||||
try {
|
||||
await options.action()
|
||||
showSuccess(options.successMessage)
|
||||
if (options.onAfterAction) await options.onAfterAction()
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
actionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return { actionLoadingId, runAction }
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ApiEnvelope } from '@/lib/http'
|
||||
import type { AdminPagination } from '@/types/admin'
|
||||
|
||||
export interface AdminListPageData<TItem> {
|
||||
items: TItem[]
|
||||
pagination: AdminPagination
|
||||
}
|
||||
|
||||
export interface UseAdminListPageOptions<TItem> {
|
||||
defaultErrorMessage: string
|
||||
pageSize?: number
|
||||
fetchPage: (page: number, pageSize: number) => Promise<ApiEnvelope<AdminListPageData<TItem>>>
|
||||
}
|
||||
|
||||
export function useAdminListPage<TItem>(options: UseAdminListPageOptions<TItem>) {
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const items = ref<TItem[]>([])
|
||||
const pagination = ref<AdminPagination>({
|
||||
page: 1,
|
||||
pageSize: options.pageSize ?? 20,
|
||||
total: 0,
|
||||
})
|
||||
let latestRequestId = 0
|
||||
|
||||
async function loadPage(page = pagination.value.page) {
|
||||
const requestId = ++latestRequestId
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await options.fetchPage(page, pagination.value.pageSize)
|
||||
|
||||
if (requestId !== latestRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
items.value = response.data.items
|
||||
pagination.value = response.data.pagination
|
||||
} catch (error) {
|
||||
if (requestId !== latestRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = error instanceof Error ? error.message : options.defaultErrorMessage
|
||||
} finally {
|
||||
if (requestId === latestRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
errorMessage,
|
||||
items,
|
||||
pagination,
|
||||
loadPage,
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
|
||||
export interface ListQueryFilters {
|
||||
[key: string]: string | string[] | undefined
|
||||
}
|
||||
|
||||
export function useAdminListQuerySync(route: RouteLocationNormalizedLoaded, router: Router) {
|
||||
function readQueryString(key: string): string {
|
||||
const value = route.query[key]
|
||||
const rawValue = Array.isArray(value) ? value[0] : value
|
||||
return typeof rawValue === 'string' ? rawValue.trim() : ''
|
||||
}
|
||||
|
||||
function readQueryPage(): number {
|
||||
const page = Number(readQueryString('page'))
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
|
||||
async function syncRouteQuery(page: number, filters: ListQueryFilters = {}) {
|
||||
const query: Record<string, string> = {}
|
||||
if (page > 1) { query.page = String(page) }
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
const trimmed = (Array.isArray(value) ? value.join(',') : value)?.trim()
|
||||
if (trimmed) { query[key] = trimmed }
|
||||
}
|
||||
await router.replace({ path: route.path, query })
|
||||
}
|
||||
|
||||
return { readQueryString, readQueryPage, syncRouteQuery }
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
AuditOutlined,
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
OrderedListOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
TeamOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { App, Button, Dropdown, Layout, Menu, Space, Typography } from 'antd'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router'
|
||||
|
||||
import { logoutAdmin } from '@/services/admin'
|
||||
import {
|
||||
clearAdminSession,
|
||||
getAdminRole,
|
||||
getAdminTokenExpiresAt,
|
||||
getAdminUsername,
|
||||
} from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'react-admin-sidebar-collapsed'
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { message } = App.useApp()
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1',
|
||||
)
|
||||
const role = getAdminRole()
|
||||
const username = getAdminUsername() || 'admin'
|
||||
const expiresAt = getAdminTokenExpiresAt()
|
||||
|
||||
const menuItems = useMemo<MenuProps['items']>(() => {
|
||||
const operationItems: MenuProps['items'] = [
|
||||
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
|
||||
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
|
||||
{ key: '/admin/cloudtentacles-records', icon: <FileSearchOutlined />, label: '发货记录' },
|
||||
]
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'group-operation',
|
||||
type: 'group',
|
||||
label: '运营',
|
||||
children: operationItems,
|
||||
},
|
||||
]
|
||||
|
||||
if (role === 'admin') {
|
||||
items.push(
|
||||
{
|
||||
key: 'group-config',
|
||||
type: 'group',
|
||||
label: '配置',
|
||||
children: [
|
||||
{ key: '/admin/users', icon: <TeamOutlined />, label: '后台用户' },
|
||||
{ key: '/admin/platform-shops', icon: <SettingOutlined />, label: '平台配置' },
|
||||
{ key: '/admin/platform-fulfillment', icon: <ShopOutlined />, label: '履约配置' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'group-system',
|
||||
type: 'group',
|
||||
label: '系统',
|
||||
children: [{ key: '/admin/audit-logs', icon: <AuditOutlined />, label: '审计日志' }],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
}, [role])
|
||||
|
||||
async function submitLogout() {
|
||||
try {
|
||||
await logoutAdmin()
|
||||
} catch {
|
||||
// 后端退出是无状态的,本地清理优先。
|
||||
} finally {
|
||||
clearAdminSession()
|
||||
message.success('已退出后台')
|
||||
navigate('/admin/login', { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollapsed() {
|
||||
const next = !collapsed
|
||||
setCollapsed(next)
|
||||
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, next ? '1' : '0')
|
||||
}
|
||||
|
||||
const selectedKey = resolveSelectedKey(location.pathname)
|
||||
|
||||
return (
|
||||
<Layout className="admin-shell">
|
||||
<Layout.Sider
|
||||
width={232}
|
||||
collapsedWidth={72}
|
||||
collapsed={collapsed}
|
||||
className="admin-sider"
|
||||
trigger={null}
|
||||
>
|
||||
<div className="admin-sider-inner">
|
||||
<div className="admin-brand">
|
||||
<div className="admin-brand-copy">
|
||||
<strong>{collapsed ? 'OS' : '运营后台'}</strong>
|
||||
{!collapsed ? <span>订单与交付管理</span> : null}
|
||||
</div>
|
||||
<Button
|
||||
className="admin-collapse-button"
|
||||
aria-label={collapsed ? '展开导航' : '折叠导航'}
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={toggleCollapsed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!collapsed ? (
|
||||
<div className="admin-sidebar-profile">
|
||||
<Typography.Text strong>{username}</Typography.Text>
|
||||
<Typography.Text type="secondary">{roleLabel(role)}</Typography.Text>
|
||||
{expiresAt ? (
|
||||
<Typography.Text type="secondary">
|
||||
{formatAdminDateTime(expiresAt)} 到期
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Menu
|
||||
mode="inline"
|
||||
inlineIndent={16}
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
className="admin-menu"
|
||||
onClick={({ key }) => navigate(String(key))}
|
||||
/>
|
||||
|
||||
<div className="admin-sider-footer">
|
||||
<Button
|
||||
block={!collapsed}
|
||||
icon={<LogoutOutlined />}
|
||||
onClick={submitLogout}
|
||||
aria-label="退出登录"
|
||||
>
|
||||
{collapsed ? null : '退出登录'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
<Layout className="admin-main-shell">
|
||||
<Layout.Header className="admin-topbar">
|
||||
<Space className="admin-user" size={12}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [{ key: 'logout', label: '退出登录' }],
|
||||
onClick: submitLogout,
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button icon={<UserOutlined />}>账号</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Layout.Header>
|
||||
<Layout.Content className="admin-content">
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSelectedKey(pathname: string) {
|
||||
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
|
||||
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
|
||||
if (pathname === '/admin/platform-shops') return '/admin/platform-shops'
|
||||
return pathname
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'operator') return '普通运营'
|
||||
return '客服'
|
||||
}
|
||||
@@ -1,64 +1,83 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { ElMessageBoxOptions, MessageOptions } from 'element-plus'
|
||||
import { Input, Modal, message as antdMessage } from 'antd'
|
||||
import type { ModalFuncProps } from 'antd'
|
||||
import { createElement } from 'react'
|
||||
|
||||
import 'element-plus/theme-chalk/el-message.css'
|
||||
import 'element-plus/theme-chalk/el-overlay.css'
|
||||
import 'element-plus/theme-chalk/el-message-box.css'
|
||||
|
||||
const baseMessageOptions: Partial<MessageOptions> = {
|
||||
grouping: true,
|
||||
type MessageOptions = {
|
||||
duration?: number
|
||||
}
|
||||
|
||||
const baseConfirmOptions: ElMessageBoxOptions = {
|
||||
type: 'warning',
|
||||
confirmButtonText: '继续执行',
|
||||
cancelButtonText: '取消',
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false,
|
||||
autofocus: false,
|
||||
distinguishCancelAndClose: true,
|
||||
type PromptResult = {
|
||||
value: string
|
||||
}
|
||||
|
||||
const basePromptOptions: ElMessageBoxOptions = {
|
||||
confirmButtonText: '提交',
|
||||
cancelButtonText: '取消',
|
||||
closeOnClickModal: false,
|
||||
closeOnPressEscape: false,
|
||||
autofocus: false,
|
||||
distinguishCancelAndClose: true,
|
||||
}
|
||||
|
||||
export function showSuccess(message: string, options: Partial<MessageOptions> = {}) {
|
||||
return ElMessage.success({
|
||||
...baseMessageOptions,
|
||||
...options,
|
||||
message,
|
||||
export function showSuccess(message: string, options: MessageOptions = {}) {
|
||||
return antdMessage.success({
|
||||
content: message,
|
||||
duration: options.duration,
|
||||
})
|
||||
}
|
||||
|
||||
export function showError(message: string, options: Partial<MessageOptions> = {}) {
|
||||
return ElMessage.error({
|
||||
...baseMessageOptions,
|
||||
...options,
|
||||
message,
|
||||
export function showError(message: string, options: MessageOptions = {}) {
|
||||
return antdMessage.error({
|
||||
content: message,
|
||||
duration: options.duration,
|
||||
})
|
||||
}
|
||||
|
||||
export function showConfirm(
|
||||
message: string,
|
||||
title = '确认操作',
|
||||
options: ElMessageBoxOptions = {},
|
||||
options: ModalFuncProps = {},
|
||||
) {
|
||||
return ElMessageBox.confirm(message, title, {
|
||||
...baseConfirmOptions,
|
||||
...options,
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
Modal.confirm({
|
||||
title,
|
||||
content: message,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
...options,
|
||||
onOk: () => {
|
||||
options.onOk?.()
|
||||
resolve()
|
||||
},
|
||||
onCancel: () => {
|
||||
options.onCancel?.()
|
||||
reject('cancel')
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function showPrompt(message: string, title: string, options: ElMessageBoxOptions = {}) {
|
||||
return ElMessageBox.prompt(message, title, {
|
||||
...basePromptOptions,
|
||||
...options,
|
||||
export function showPrompt(message: string, title: string, options: ModalFuncProps = {}) {
|
||||
let value = ''
|
||||
|
||||
return new Promise<PromptResult>((resolve, reject) => {
|
||||
Modal.confirm({
|
||||
title,
|
||||
content: createElement('div', { className: 'feedback-prompt' }, [
|
||||
createElement('p', { key: 'message' }, message),
|
||||
createElement(Input, {
|
||||
key: 'input',
|
||||
autoFocus: true,
|
||||
onChange: (event) => {
|
||||
value = event.target.value
|
||||
},
|
||||
}),
|
||||
]),
|
||||
okText: '提交',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
...options,
|
||||
onOk: () => {
|
||||
options.onOk?.()
|
||||
resolve({ value })
|
||||
},
|
||||
onCancel: () => {
|
||||
options.onCancel?.()
|
||||
reject('cancel')
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/main.css'
|
||||
import './styles/element-overrides.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App as AntdApp, ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import 'antd/dist/reset.css'
|
||||
|
||||
import App from './App'
|
||||
import './styles/main.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 6,
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
bodyBg: '#f5f7fb',
|
||||
headerBg: '#ffffff',
|
||||
siderBg: '#ffffff',
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 6,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Button, Empty } from 'antd'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
type NotFoundPageProps = {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function NotFoundPage({ title = '页面不存在' }: NotFoundPageProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<main className="empty-page">
|
||||
<Empty description={title}>
|
||||
<Button type="primary" onClick={() => navigate('/admin/dashboard')}>
|
||||
返回概览
|
||||
</Button>
|
||||
</Empty>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, DatePicker, Empty, Form, Input, Select, Space, Table, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminAuditLogs } from '@/services/admin'
|
||||
import type { AdminAuditLogItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAuditAction, formatAuditTargetType } from '@/utils/admin-display'
|
||||
import { adminAuditActionOptions, adminAuditTargetTypeOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { stringifyDisplayJson } from '@/utils/date-time'
|
||||
|
||||
type AuditFilterForm = {
|
||||
actorUsername?: string
|
||||
action?: string
|
||||
targetType?: string
|
||||
dateRange?: unknown
|
||||
}
|
||||
|
||||
export default function AdminAuditLogsPage() {
|
||||
const isAdmin = hasAdminRole('admin')
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||
const filters = {
|
||||
actorUsername: searchParams.get('actorUsername') || '',
|
||||
action: searchParams.get('action') || '',
|
||||
targetType: searchParams.get('targetType') || '',
|
||||
dateFrom: searchParams.get('dateFrom') || '',
|
||||
dateTo: searchParams.get('dateTo') || '',
|
||||
}
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
[
|
||||
filters.action,
|
||||
filters.actorUsername,
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.targetType,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-audit-logs', queryParams],
|
||||
enabled: isAdmin,
|
||||
queryFn: () => fetchAdminAuditLogs(queryParams),
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
const columns: TableColumnsType<AdminAuditLogItem> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
minWidth: 140,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{row.actorUsername || '-'}</Typography.Text>
|
||||
<StatusTag value={row.actorRole} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '动作',
|
||||
dataIndex: 'action',
|
||||
minWidth: 180,
|
||||
render: (value) => <Typography.Text strong>{formatAuditAction(value)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '目标',
|
||||
minWidth: 180,
|
||||
render: (_, row) => (
|
||||
<Typography.Text strong>
|
||||
{formatAuditTargetType(row.targetType)} #{row.targetId || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
minWidth: 260,
|
||||
render: (_, row) => <span className="payload-text">{formatPayload(row.payload)}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
function applyFilters(values: AuditFilterForm) {
|
||||
const [dateFrom, dateTo] = Array.isArray(values.dateRange)
|
||||
? values.dateRange.map((item: { format?: (pattern: string) => string }) =>
|
||||
item?.format ? item.format('YYYY-MM-DD') : '',
|
||||
)
|
||||
: ['', '']
|
||||
|
||||
setSearchParams(
|
||||
cleanParams({
|
||||
actorUsername: values.actorUsername,
|
||||
action: values.action,
|
||||
targetType: values.targetType,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: 1,
|
||||
pageSize,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="操作审计"
|
||||
description="集中查看高风险后台动作,便于排查谁在什么时间改了什么。"
|
||||
extra={<Typography.Text type="secondary">共 {data?.pagination.total || 0} 条审计记录</Typography.Text>}
|
||||
/>
|
||||
|
||||
{!isAdmin ? (
|
||||
<Alert type="warning" showIcon message="仅管理员可以访问操作审计" />
|
||||
) : (
|
||||
<>
|
||||
<Card title="筛选审计日志">
|
||||
<Form<AuditFilterForm>
|
||||
layout="inline"
|
||||
initialValues={{
|
||||
actorUsername: filters.actorUsername,
|
||||
action: filters.action,
|
||||
targetType: filters.targetType,
|
||||
dateRange:
|
||||
filters.dateFrom && filters.dateTo
|
||||
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
|
||||
: undefined,
|
||||
}}
|
||||
onFinish={applyFilters}
|
||||
className="filter-form"
|
||||
>
|
||||
<Form.Item name="actorUsername">
|
||||
<Input allowClear placeholder="操作账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="action">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="动作"
|
||||
options={adminAuditActionOptions}
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="targetType">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="目标类型"
|
||||
options={adminAuditTargetTypeOptions}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{query.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '读取审计日志失败'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Table<AdminAuditLogItem>
|
||||
rowKey="logId"
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
locale={{ emptyText: <Empty description="暂无审计记录" /> }}
|
||||
scroll={{ x: 980 }}
|
||||
pagination={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSearchParams(
|
||||
cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }),
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatPayload(payload: Record<string, unknown>) {
|
||||
const text = stringifyDisplayJson(payload, 0)
|
||||
return text.length > 140 ? `${text.slice(0, 140)}...` : text
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (normalized) {
|
||||
next.set(key, normalized)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
import {
|
||||
DownloadOutlined,
|
||||
FileImageOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Image as AntdImage,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminCloudtentaclesDeliveryRecords,
|
||||
fetchAdminCloudtentaclesRecordSources,
|
||||
} from '@/services/admin'
|
||||
import type { AdminCloudtentaclesDeliveryRecordItem } from '@/types/admin'
|
||||
import { getAdminToken } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type RecordSourceOption = {
|
||||
key: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
hasToken: boolean
|
||||
loggedInAt: string
|
||||
}
|
||||
|
||||
type DateRangeValue = [Dayjs | null, Dayjs | null] | null
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 100
|
||||
const PAGE_SIZE_OPTIONS = [100, 200, 500, 1000]
|
||||
|
||||
export default function AdminCloudtentaclesRecordsPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sourceLoading, setSourceLoading] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [sourceKey, setSourceKey] = useState('')
|
||||
const [platformOrderId, setPlatformOrderId] = useState('')
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(createDefaultDateRange())
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [items, setItems] = useState<AdminCloudtentaclesDeliveryRecordItem[]>([])
|
||||
const [sourceOptions, setSourceOptions] = useState<RecordSourceOption[]>([])
|
||||
const [previewVisible, setPreviewVisible] = useState(false)
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState('')
|
||||
const [previewFileName, setPreviewFileName] = useState('')
|
||||
|
||||
const currentSourceLabel = useMemo(() => {
|
||||
const matched = sourceOptions.find((source) => source.key === sourceKey)
|
||||
return matched?.label || sourceKey || '-'
|
||||
}, [sourceKey, sourceOptions])
|
||||
|
||||
const columns: TableColumnsType<AdminCloudtentaclesDeliveryRecordItem> = [
|
||||
{
|
||||
title: '商品 / 角色',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.name || '-' }}>
|
||||
{row.name || '-'}
|
||||
</Typography.Text>
|
||||
<span className="muted">账号:{row.fulfillUser || '-'}</span>
|
||||
{row.roleName || row.roleId ? (
|
||||
<span className="muted">
|
||||
角色:{row.roleName || '-'}
|
||||
{row.roleId ? `(${row.roleId})` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单 / 店铺',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.platformOrderId || '-' }}>
|
||||
{row.platformOrderId || '-'}
|
||||
</Typography.Text>
|
||||
<span className="muted">订单店铺:{row.orderShopName || row.orderShopId || '-'}</span>
|
||||
<span className="muted">核销店铺:{row.consumeShopName || row.consumeShopId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '虚拟号',
|
||||
minWidth: 160,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.phone || '-'}</span>
|
||||
<span className="muted">VN {row.virtualNumberId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<div className="status-stack">
|
||||
<Tag color={getStatusColor(row.status)}>{getStatusLabel(row.status)}</Tag>
|
||||
{row.localDispatchStatus ? (
|
||||
<span className="muted">{getLocalDispatchStatusLabel(row.localDispatchStatus)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
minWidth: 120,
|
||||
render: (_, row) => row.operatorName || '-',
|
||||
},
|
||||
{
|
||||
title: '发货时间',
|
||||
minWidth: 170,
|
||||
render: (_, row) => formatAdminDateTime(row.createdAt),
|
||||
},
|
||||
{
|
||||
title: '记录 ID',
|
||||
minWidth: 230,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: row.recordId || '-' }}>
|
||||
{row.recordId || '-'}
|
||||
</Typography.Text>
|
||||
{row.taskNo ? (
|
||||
<span className="muted">
|
||||
任务:{row.taskNo}
|
||||
{getMatchConfidenceLabel(row.matchConfidence)
|
||||
? ` · 匹配${getMatchConfidenceLabel(row.matchConfidence)}`
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
fixed: 'right',
|
||||
width: 136,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<FileImageOutlined />}
|
||||
onClick={() => generateRecordImage(row)}
|
||||
>
|
||||
生成图片
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap()
|
||||
}, [])
|
||||
|
||||
async function bootstrap() {
|
||||
const nextSourceKey = await loadSources()
|
||||
if (nextSourceKey) {
|
||||
await loadRecords(1, { sourceKey: nextSourceKey })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
setSourceLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesRecordSources()
|
||||
const nextSources = response.data.sources || []
|
||||
const usableSources = nextSources.filter((source) => source.enabled && source.hasToken)
|
||||
const nextSourceKey = sourceKey || usableSources[0]?.key || nextSources[0]?.key || ''
|
||||
setSourceOptions(nextSources)
|
||||
setSourceKey(nextSourceKey)
|
||||
return nextSourceKey
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 账号失败')
|
||||
return ''
|
||||
} finally {
|
||||
setSourceLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecords(
|
||||
nextPage = page,
|
||||
options: {
|
||||
sourceKey?: string
|
||||
pageSize?: number
|
||||
platformOrderId?: string
|
||||
dateRange?: DateRangeValue
|
||||
} = {},
|
||||
) {
|
||||
const nextSourceKey = options.sourceKey ?? sourceKey
|
||||
const nextPageSize = options.pageSize ?? pageSize
|
||||
const nextDateRange = options.dateRange ?? dateRange
|
||||
const orderKeyword = (options.platformOrderId ?? platformOrderId).trim()
|
||||
|
||||
if (!nextSourceKey && !orderKeyword) {
|
||||
showError('请先选择 cloudtentacles 账号')
|
||||
return
|
||||
}
|
||||
|
||||
const [startDate, endDate] = nextDateRange || []
|
||||
if (!startDate || !endDate) {
|
||||
showError('请选择查询时间范围')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesDeliveryRecords({
|
||||
sourceKey: nextSourceKey || undefined,
|
||||
page: nextPage,
|
||||
size: nextPageSize,
|
||||
startDate: formatQueryDateTime(startDate),
|
||||
endDate: formatQueryDateTime(endDate),
|
||||
platformOrderId: orderKeyword || undefined,
|
||||
})
|
||||
|
||||
if (response.data.sourceKey && response.data.sourceKey !== sourceKey) {
|
||||
setSourceKey(response.data.sourceKey)
|
||||
}
|
||||
|
||||
setPage(response.data.page)
|
||||
setPageSize(response.data.size)
|
||||
setTotal(response.data.total)
|
||||
setItems(response.data.items || [])
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '查询发货记录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
const nextDateRange = createDefaultDateRange()
|
||||
setPlatformOrderId('')
|
||||
setDateRange(nextDateRange)
|
||||
setPage(1)
|
||||
setPageSize(DEFAULT_PAGE_SIZE)
|
||||
void loadRecords(1, {
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
platformOrderId: '',
|
||||
dateRange: nextDateRange,
|
||||
})
|
||||
}
|
||||
|
||||
async function generateRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
try {
|
||||
const dataUrl = await drawRecordImage(record)
|
||||
setPreviewImageUrl(dataUrl)
|
||||
setPreviewFileName(buildRecordImageFileName(record))
|
||||
setPreviewVisible(true)
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '生成发货记录图片失败')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPreviewImage() {
|
||||
if (!previewImageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = previewImageUrl
|
||||
link.download = previewFileName || 'cloudtentacles-delivery-record.png'
|
||||
link.click()
|
||||
showSuccess('发货记录图片已生成')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="查询发货记录"
|
||||
description="按订单号、cloudtentacles 账号和时间范围查询平台发货记录。"
|
||||
extra={<span className="total-badge">共 {total} 条记录</span>}
|
||||
/>
|
||||
|
||||
<Card
|
||||
title="查询条件"
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
输入订单号时会先关联本地任务,再匹配 cloudtentacles 发货记录。
|
||||
</Typography.Text>
|
||||
}
|
||||
>
|
||||
<div className="cloud-records-filter">
|
||||
<Select
|
||||
value={sourceKey || undefined}
|
||||
loading={sourceLoading}
|
||||
placeholder="cloudtentacles 账号"
|
||||
options={sourceOptions.map((source) => ({
|
||||
label: `${source.label || source.key}${source.hasToken ? '' : ' · 未登录'}`,
|
||||
value: source.key,
|
||||
disabled: !source.enabled || !source.hasToken,
|
||||
}))}
|
||||
onChange={setSourceKey}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
value={platformOrderId}
|
||||
placeholder="订单号"
|
||||
onChange={(event) => setPlatformOrderId(event.target.value)}
|
||||
onPressEnter={() => loadRecords(1)}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
showTime
|
||||
value={dateRange}
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
className="full-width"
|
||||
onChange={(value) => setDateRange(value)}
|
||||
/>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={() => loadRecords(1)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<Card>
|
||||
<div className="table-toolbar">
|
||||
<strong>发货记录</strong>
|
||||
<span>
|
||||
账号 {currentSourceLabel} · 第 {page} 页,当前展示 {items.length} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Table<AdminCloudtentaclesDeliveryRecordItem>
|
||||
rowKey={(row) => row.recordId || `${row.virtualNumberId}-${row.createdAt}`}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
size="small"
|
||||
scroll={{ x: 1450 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
showSizeChanger: true,
|
||||
showTotal: (nextTotal) => `共 ${nextTotal} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
void loadRecords(nextPage, { pageSize: nextPageSize })
|
||||
},
|
||||
}}
|
||||
locale={{ emptyText: loading ? '发货记录加载中' : '当前筛选条件下暂无发货记录' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="发货记录图片"
|
||||
open={previewVisible}
|
||||
width={780}
|
||||
destroyOnHidden
|
||||
onCancel={() => setPreviewVisible(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setPreviewVisible(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
<Button
|
||||
key="download"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={downloadPreviewImage}
|
||||
>
|
||||
下载图片
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="record-preview">
|
||||
{previewImageUrl ? (
|
||||
<AntdImage src={previewImageUrl} alt="发货记录图片预览" preview={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusLabel(status: number) {
|
||||
if (status === 4) return '已完成'
|
||||
if (status === 1) return '处理中'
|
||||
if (status === 2) return '待处理'
|
||||
if (status === 3) return '失败'
|
||||
return `状态 ${status || '-'}`
|
||||
}
|
||||
|
||||
function getStatusColor(status: number) {
|
||||
if (status === 4) return 'green'
|
||||
if (status === 3) return 'red'
|
||||
if (status === 1) return 'orange'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function getLocalDispatchStatusLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (normalized === 'success') return '本地已发货'
|
||||
if (normalized === 'failed') return '本地发货失败'
|
||||
if (normalized === 'pending') return '本地待发货'
|
||||
return normalized || '-'
|
||||
}
|
||||
|
||||
function getMatchConfidenceLabel(value: string) {
|
||||
if (value === 'high') return '高'
|
||||
if (value === 'medium') return '中'
|
||||
if (value === 'low') return '低'
|
||||
if (value === 'local') return '本地'
|
||||
return ''
|
||||
}
|
||||
|
||||
function createDefaultDateRange(): DateRangeValue {
|
||||
const end = dayjs()
|
||||
return [end.subtract(7, 'day').startOf('day'), end]
|
||||
}
|
||||
|
||||
function formatQueryDateTime(value: Dayjs) {
|
||||
return value.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const productImage = await loadRecordProductImage(record.skuImage)
|
||||
const canvas = document.createElement('canvas')
|
||||
const scale = window.devicePixelRatio || 1
|
||||
const width = 720
|
||||
const height = 330
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('当前浏览器不支持图片生成')
|
||||
}
|
||||
|
||||
ctx.scale(scale, scale)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.fillStyle = '#1f2937'
|
||||
ctx.font = '20px sans-serif'
|
||||
ctx.fillText('订单详情', 28, 38)
|
||||
ctx.fillStyle = '#9ca3af'
|
||||
ctx.font = '24px sans-serif'
|
||||
ctx.fillText('x', width - 40, 38)
|
||||
|
||||
ctx.fillStyle = '#111827'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`购买时间:${formatAdminDateTime(record.createdAt)}`, width / 2, 88)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
drawTicketCard(ctx, record, productImage)
|
||||
drawRecordMeta(ctx, record)
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
function drawTicketCard(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
record: AdminCloudtentaclesDeliveryRecordItem,
|
||||
productImage: HTMLImageElement | null,
|
||||
) {
|
||||
const x = 170
|
||||
const y = 108
|
||||
const width = 392
|
||||
const height = 108
|
||||
const imageWidth = 106
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + 18, y)
|
||||
ctx.lineTo(x + width, y)
|
||||
ctx.lineTo(x + width, y + height - 22)
|
||||
ctx.lineTo(x + width - 18, y + height)
|
||||
ctx.lineTo(x, y + height)
|
||||
ctx.lineTo(x, y + 18)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = '#f8fbff'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#2f80d1'
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
const gradient = ctx.createLinearGradient(x, y, x + imageWidth, y + height)
|
||||
gradient.addColorStop(0, '#e7f1ff')
|
||||
gradient.addColorStop(1, '#b8d7ff')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, y, imageWidth, height)
|
||||
|
||||
if (productImage) {
|
||||
drawCoverImage(ctx, productImage, x, y, imageWidth, height)
|
||||
} else {
|
||||
ctx.fillStyle = '#355d91'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('套装发货记录', x + imageWidth / 2, y + 25)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(x + 34, y + 42, 38, 38)
|
||||
ctx.strokeStyle = '#8ab6ef'
|
||||
ctx.strokeRect(x + 34, y + 42, 38, 38)
|
||||
ctx.fillStyle = '#2f80d1'
|
||||
ctx.font = '20px sans-serif'
|
||||
ctx.fillText('GP', x + imageWidth / 2, y + 69)
|
||||
}
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
const contentX = x + imageWidth + 16
|
||||
const orderText = `订单号:${record.platformOrderId || record.recordId.slice(-8) || '-'}`
|
||||
ctx.fillStyle = '#1d5ea8'
|
||||
ctx.font = '16px sans-serif'
|
||||
drawEllipsisText(ctx, record.name || '未命名商品', contentX, y + 31, 170)
|
||||
ctx.fillStyle = '#3478c6'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(orderText, x + width - 12, y + 30)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
ctx.strokeStyle = '#c8d8ec'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(contentX, y + 42)
|
||||
ctx.lineTo(x + width - 12, y + 42)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#f59e0b'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.fillText(`购买机会 x ${record.count || 1}`, contentX, y + 66)
|
||||
ctx.fillStyle = '#16a34a'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(getStatusLabel(record.status), x + width - 12, y + 66)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
ctx.fillStyle = '#3478c6'
|
||||
ctx.font = '14px sans-serif'
|
||||
drawEllipsisText(ctx, `账号:${record.fulfillUser || record.phone || '-'}`, contentX, y + 90, 250)
|
||||
}
|
||||
|
||||
function drawRecordMeta(ctx: CanvasRenderingContext2D, record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const left = 80
|
||||
const top = 250
|
||||
const lineHeight = 22
|
||||
const values = [
|
||||
`订单店铺:${record.orderShopName || record.orderShopId || '-'}`,
|
||||
`核销店铺:${record.consumeShopName || record.consumeShopId || '-'}`,
|
||||
`绑定角色:${record.roleName || record.fulfillUser || '-'}${record.roleId ? `(${record.roleId})` : ''}`,
|
||||
]
|
||||
|
||||
ctx.fillStyle = '#374151'
|
||||
ctx.font = '13px sans-serif'
|
||||
values.forEach((value, index) => {
|
||||
drawEllipsisText(ctx, value, left, top + index * lineHeight, 560)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadRecordProductImage(imageUrl: string) {
|
||||
const normalizedUrl = String(imageUrl || '').trim()
|
||||
if (!normalizedUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const proxyUrl = `/api/v1/admin/cloudtentacles-records/image?url=${encodeURIComponent(
|
||||
normalizedUrl,
|
||||
)}`
|
||||
return loadProtectedImage(proxyUrl).catch(() => null)
|
||||
}
|
||||
|
||||
async function loadProtectedImage(src: string) {
|
||||
const token = getAdminToken()
|
||||
const response = await fetch(src, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('商品图片加载失败')
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(await response.blob())
|
||||
try {
|
||||
return await loadImage(objectUrl)
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error('商品图片加载失败'))
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
|
||||
function drawCoverImage(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const ratio = Math.max(width / image.naturalWidth, height / image.naturalHeight)
|
||||
const drawWidth = image.naturalWidth * ratio
|
||||
const drawHeight = image.naturalHeight * ratio
|
||||
const drawX = x + (width - drawWidth) / 2
|
||||
const drawY = y + (height - drawHeight) / 2
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(x, y, width, height)
|
||||
ctx.clip()
|
||||
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawEllipsisText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
) {
|
||||
let output = text
|
||||
while (output.length > 0 && ctx.measureText(output).width > maxWidth) {
|
||||
output = `${output.slice(0, -2)}...`
|
||||
}
|
||||
ctx.fillText(output || '-', x, y)
|
||||
}
|
||||
|
||||
function buildRecordImageFileName(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const name = sanitizeFileName(record.name || '发货记录')
|
||||
const id = sanitizeFileName(
|
||||
record.platformOrderId ||
|
||||
record.recordId.slice(0, 8) ||
|
||||
String(record.virtualNumberId || 'record'),
|
||||
)
|
||||
return `${name}-${id}.png`
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 48)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Alert, Card, Col, Row, Skeleton, Statistic } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { fetchAdminDashboardSummary } from '@/services/admin'
|
||||
|
||||
const cards = [
|
||||
{ key: 'todayOrders', label: '今日订单' },
|
||||
{ key: 'paidPendingClaim', label: '待领取' },
|
||||
{ key: 'claimingTasks', label: '领取中' },
|
||||
{ key: 'redeemedToday', label: '今日成功' },
|
||||
{ key: 'abnormalTasks', label: '异常任务' },
|
||||
] as const
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-dashboard-summary'],
|
||||
queryFn: () => fetchAdminDashboardSummary(),
|
||||
})
|
||||
const summary = query.data?.data
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="系统概览" description="快速查看订单和交付链路的运行状态。" />
|
||||
|
||||
{query.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '读取概览失败'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{query.isLoading ? (
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</Card>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{cards.map((card) => (
|
||||
<Col xs={24} md={12} xl={8} key={card.key}>
|
||||
<Card>
|
||||
<Statistic title={card.label} value={summary?.[card.key] ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { App, Button, Card, Form, Input, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { loginAdmin } from '@/services/admin'
|
||||
import { setAdminSession } from '@/utils/admin-auth'
|
||||
|
||||
type LoginForm = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function submitLogin(values: LoginForm) {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await loginAdmin({
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
})
|
||||
setAdminSession(response.data.token, response.data.expiresAt, response.data.user)
|
||||
message.success('登录成功')
|
||||
navigate('/admin/dashboard', { replace: true })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '后台登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<Card className="login-panel" variant="borderless">
|
||||
<div className="login-heading">
|
||||
<Typography.Text className="eyebrow">Order Site Admin</Typography.Text>
|
||||
<Typography.Title level={1}>运营后台登录</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
使用账号密码进入后台,并按角色开放不同操作权限。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
|
||||
<Form<LoginForm> layout="vertical" requiredMark={false} onFinish={submitLogin}>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="账号"
|
||||
rules={[{ required: true, message: '请输入账号' }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="输入账号" autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="密码"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="输入密码"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登录后台
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Descriptions, Skeleton, Space, Table, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminOrderDetail } from '@/services/admin'
|
||||
import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
export default function AdminOrderDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const { orderId = '' } = useParams()
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-order-detail', orderId],
|
||||
queryFn: () => fetchAdminOrderDetail(orderId),
|
||||
enabled: Boolean(orderId),
|
||||
})
|
||||
const detail = query.data?.data
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="订单详情" extra={<BackButton />} />
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.error || !detail) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="订单详情" extra={<BackButton />} />
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '订单不存在或读取失败'}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const taskColumns: TableColumnsType<AdminTaskListItem> = [
|
||||
{
|
||||
title: '任务号',
|
||||
dataIndex: 'taskNo',
|
||||
minWidth: 180,
|
||||
render: (_, row) => (
|
||||
<Typography.Link onClick={() => navigate(`/admin/tasks/${row.taskId}`)}>
|
||||
{row.taskNo}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={[0, 6]} wrap>
|
||||
<StatusTag value={row.status} kind="task" />
|
||||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||||
<StatusTag value={row.customerStatus} kind="customer" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
minWidth: 160,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.roleName || '-'}</span>
|
||||
<span className="muted">{row.roleId || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '错误',
|
||||
dataIndex: 'lastError',
|
||||
minWidth: 220,
|
||||
render: (value) => <span className="muted">{value || '-'}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="订单详情"
|
||||
description={detail.order.platformOrderId}
|
||||
extra={<BackButton />}
|
||||
/>
|
||||
|
||||
<Card title="基础信息">
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="订单 ID">{detail.order.orderId}</Descriptions.Item>
|
||||
<Descriptions.Item label="店铺">
|
||||
{detail.order.shopName || detail.order.shopId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">{detail.order.platformOrderId}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单状态">
|
||||
<StatusTag value={detail.order.orderStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付状态">
|
||||
<StatusTag value={detail.order.payStatus} kind="pay" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">
|
||||
{detail.order.totalAmount} {detail.order.currency}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{formatAdminDateTime(detail.order.createdAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{formatAdminDateTime(detail.order.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品摘要">{detail.order.itemSummary || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="商品明细">
|
||||
<Table<AdminOrderDetail['items'][number]>
|
||||
rowKey="orderItemId"
|
||||
pagination={false}
|
||||
dataSource={detail.items}
|
||||
scroll={{ x: 760 }}
|
||||
columns={[
|
||||
{ title: '商品', dataIndex: 'itemTitle', minWidth: 240 },
|
||||
{ title: 'SKU', dataIndex: 'skuCode', minWidth: 180 },
|
||||
{ title: '数量', dataIndex: 'quantity', width: 90 },
|
||||
{ title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="任务">
|
||||
<Table<AdminTaskListItem>
|
||||
rowKey="taskId"
|
||||
pagination={false}
|
||||
dataSource={detail.tasks}
|
||||
columns={taskColumns}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="原始载荷">
|
||||
<JsonPreview value={detail.order.rawPayload} />
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function BackButton() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/orders')}>
|
||||
返回订单
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { SearchOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { Button, Card, Form, Input, Select, Space, Table, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import { fetchAdminOrders } from '@/services/admin'
|
||||
import type { AdminOrderListItem } from '@/types/admin'
|
||||
import { adminPayStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type OrderFilterForm = {
|
||||
platformOrderId?: string
|
||||
payStatus?: string
|
||||
skuCode?: string
|
||||
}
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||
const filters = {
|
||||
platformOrderId: searchParams.get('platformOrderId') || '',
|
||||
payStatus: searchParams.get('payStatus') || '',
|
||||
skuCode: searchParams.get('skuCode') || '',
|
||||
}
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
[filters.platformOrderId, filters.payStatus, filters.skuCode, page, pageSize],
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-orders', queryParams],
|
||||
queryFn: () => fetchAdminOrders(queryParams),
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
const columns: TableColumnsType<AdminOrderListItem> = [
|
||||
{
|
||||
title: '订单信息',
|
||||
dataIndex: 'platformOrderId',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Link onClick={() => navigate(`/admin/orders/${row.orderId}`)}>
|
||||
{row.platformOrderId}
|
||||
</Typography.Link>
|
||||
<span className="muted">{row.shopName || row.shopId || row.provider}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品 / 任务',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.itemSummary || '-'}</span>
|
||||
<span className="muted">
|
||||
商品 {row.itemCount} · 数量 {row.totalQuantity} · 任务 {row.taskCount}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={[0, 6]} wrap>
|
||||
<StatusTag value={row.payStatus} kind="pay" />
|
||||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||||
<StatusTag value={row.customerStatus} kind="customer" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额 / 时间',
|
||||
minWidth: 180,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<strong>{row.totalAmount || '0.00'} {row.currency}</strong>
|
||||
<span className="muted">{formatAdminDateTime(row.createdAt)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
function applyFilters(values: OrderFilterForm) {
|
||||
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="订单" description="查看订单入库、商品匹配和任务拆分情况。" />
|
||||
|
||||
<Card>
|
||||
<Form<OrderFilterForm>
|
||||
layout="inline"
|
||||
initialValues={filters}
|
||||
onFinish={applyFilters}
|
||||
className="filter-form"
|
||||
>
|
||||
<Form.Item name="platformOrderId">
|
||||
<Input allowClear placeholder="平台订单号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="payStatus">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="支付状态"
|
||||
options={adminPayStatusOptions}
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="skuCode">
|
||||
<Input allowClear placeholder="SKU 标识" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Table<AdminOrderListItem>
|
||||
rowKey="orderId"
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (normalized) {
|
||||
next.set(key, normalized)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
ReloadOutlined,
|
||||
RollbackOutlined,
|
||||
SendOutlined,
|
||||
SwapOutlined,
|
||||
ToolOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Image,
|
||||
Input,
|
||||
Skeleton,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import {
|
||||
closeAdminTask,
|
||||
completeAdminTaskManualDispatch,
|
||||
dispatchAdminTaskKuaishouCloudFulfillment,
|
||||
fetchAdminTaskDetail,
|
||||
fetchAdminTaskScreenshot,
|
||||
markAdminTaskManualReview,
|
||||
prepareAdminTaskKuaishouCloudFulfillment,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
refreshAdminTaskKuaishouCloudRoleInfo,
|
||||
retryAdminTask,
|
||||
returnNumberAdminTaskKuaishouCloudFulfillment,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import {
|
||||
formatKuaishouRoleInfoLabel,
|
||||
formatRedeemOutcomeLabel,
|
||||
formatRedeemResolutionStatus,
|
||||
formatTaskEventPayload,
|
||||
formatTaskEventType,
|
||||
} from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type TaskEvent = AdminTaskDetail['events'][number]
|
||||
type ManualDispatchForm = {
|
||||
deliveryReference: string
|
||||
deliveredCredential: string
|
||||
resultMessage: string
|
||||
}
|
||||
|
||||
export default function AdminTaskDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const { taskId = '' } = useParams()
|
||||
const { message, modal } = App.useApp()
|
||||
const [actionLoadingKey, setActionLoadingKey] = useState('')
|
||||
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||||
const [screenshotPreviewUrl, setScreenshotPreviewUrl] = useState('')
|
||||
const [manualDispatchForm, setManualDispatchForm] = useState<ManualDispatchForm>({
|
||||
deliveryReference: '',
|
||||
deliveredCredential: '',
|
||||
resultMessage: '',
|
||||
})
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-task-detail', taskId],
|
||||
queryFn: () => fetchAdminTaskDetail(taskId),
|
||||
enabled: Boolean(taskId),
|
||||
})
|
||||
const detail = query.data?.data
|
||||
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||
const canCloseTasks = hasAdminRole('support')
|
||||
const claimUrl = useMemo(() => {
|
||||
const tokenStatus = String(detail?.claimToken?.status || '').trim()
|
||||
const taskStatus = String(detail?.task.status || '').trim()
|
||||
|
||||
if (tokenStatus !== 'active' || ['closed', 'expired'].includes(taskStatus)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return lastClaimUrl || detail?.claimToken?.claimUrl || ''
|
||||
}, [detail?.claimToken?.claimUrl, detail?.claimToken?.status, detail?.task.status, lastClaimUrl])
|
||||
const claimLinkInvalid = Boolean(
|
||||
detail?.claimToken?.claimUrl &&
|
||||
String(detail.claimToken.status || '').trim() &&
|
||||
String(detail.claimToken.status || '').trim() !== 'active',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail) return
|
||||
|
||||
setManualDispatchForm({
|
||||
deliveryReference: detail.manualDispatch?.deliveryReference || '',
|
||||
deliveredCredential: detail.manualDispatch?.deliveredCredential || '',
|
||||
resultMessage: detail.manualDispatch?.resultMessage || detail.task.resultMessage || '',
|
||||
})
|
||||
}, [detail])
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl = ''
|
||||
setScreenshotPreviewUrl('')
|
||||
|
||||
if (!detail?.screenshotUrl) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
fetchAdminTaskScreenshot(detail.task.taskId)
|
||||
.then((blob) => {
|
||||
objectUrl = URL.createObjectURL(blob)
|
||||
setScreenshotPreviewUrl(objectUrl)
|
||||
})
|
||||
.catch(() => {
|
||||
setScreenshotPreviewUrl('')
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
}, [detail?.screenshotUrl, detail?.task.taskId])
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="任务详情" extra={<BackButton />} />
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.error || !detail) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="任务详情" extra={<BackButton />} />
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '任务不存在或读取失败'}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedDetail = detail
|
||||
const flow = resolvedDetail.kuaishouCloudFulfillment
|
||||
const eventColumns: TableColumnsType<TaskEvent> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 180,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'eventType',
|
||||
width: 180,
|
||||
render: (value) => formatTaskEventType(value),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
render: (_, row) => formatTaskEventPayload(row.payload),
|
||||
},
|
||||
]
|
||||
|
||||
async function copyClaimUrl() {
|
||||
if (!claimUrl) return
|
||||
await navigator.clipboard.writeText(claimUrl)
|
||||
message.success('领取链接已复制')
|
||||
}
|
||||
|
||||
function runTaskAction(
|
||||
actionKey: string,
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>,
|
||||
successMessage: string,
|
||||
confirmText: string,
|
||||
) {
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: confirmText,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingKey(actionKey)
|
||||
try {
|
||||
const response = await action()
|
||||
if (response.data.claimUrl) {
|
||||
setLastClaimUrl(response.data.claimUrl)
|
||||
}
|
||||
message.success(successMessage)
|
||||
await query.refetch()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
setActionLoadingKey('')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitManualDispatch(outcome: 'delivered' | 'failed') {
|
||||
const actionLabel = outcome === 'failed' ? '标记履约失败' : '标记已完成履约'
|
||||
const confirmText =
|
||||
outcome === 'failed'
|
||||
? `确认把任务 ${resolvedDetail.task.taskNo} 回写为人工履约失败吗?这会把任务直接收口并保留失败原因。`
|
||||
: `确认把任务 ${resolvedDetail.task.taskNo} 回写为人工履约完成吗?这会把任务直接标记为已发放。`
|
||||
|
||||
runTaskAction(
|
||||
`manual-${outcome}`,
|
||||
() =>
|
||||
completeAdminTaskManualDispatch(resolvedDetail.task.taskId, {
|
||||
outcome,
|
||||
resultMessage: manualDispatchForm.resultMessage,
|
||||
deliveryReference: manualDispatchForm.deliveryReference,
|
||||
deliveredCredential: manualDispatchForm.deliveredCredential,
|
||||
}),
|
||||
actionLabel,
|
||||
confirmText,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="任务详情"
|
||||
description={resolvedDetail.task.taskNo}
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => query.refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
<BackButton />
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<TaskActionPanel
|
||||
detail={resolvedDetail}
|
||||
canManageTaskLifecycle={canManageTaskLifecycle}
|
||||
canCloseTasks={canCloseTasks}
|
||||
actionLoadingKey={actionLoadingKey}
|
||||
onRetry={() =>
|
||||
runTaskAction(
|
||||
'retry',
|
||||
() => retryAdminTask(resolvedDetail.task.taskId),
|
||||
'任务已重试',
|
||||
`确认重试任务 ${resolvedDetail.task.taskNo} 吗?`,
|
||||
)
|
||||
}
|
||||
onRebindRole={() =>
|
||||
runTaskAction(
|
||||
'rebind-role',
|
||||
() => rebindAdminTaskKuaishouCloudRole(resolvedDetail.task.taskId),
|
||||
'角色换绑资源已准备完成',
|
||||
`确认为任务 ${resolvedDetail.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
)
|
||||
}
|
||||
onPrepareKuaishouCloud={() =>
|
||||
runTaskAction(
|
||||
'prepare-kuaishou-cloud',
|
||||
() => prepareAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
|
||||
'绑定资源已准备完成',
|
||||
`确认开始为任务 ${resolvedDetail.task.taskNo} 准备绑定资源吗?系统会自动检查背包、余额并申请虚拟号。`,
|
||||
)
|
||||
}
|
||||
onDispatchKuaishouCloud={() =>
|
||||
runTaskAction(
|
||||
'dispatch-kuaishou-cloud',
|
||||
() => dispatchAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
|
||||
'已完成绑定确认并发货',
|
||||
`确认客户已经完成绑定,并立即为任务 ${resolvedDetail.task.taskNo} 执行发货吗?这个动作会把"确认绑定完成"和"发货"合并为一步。`,
|
||||
)
|
||||
}
|
||||
onReturnKuaishouCloud={() =>
|
||||
runTaskAction(
|
||||
'return-kuaishou-cloud',
|
||||
() => returnNumberAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId),
|
||||
'号码已退还',
|
||||
`确认退还任务 ${resolvedDetail.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`,
|
||||
)
|
||||
}
|
||||
onMarkManualReview={() =>
|
||||
runTaskAction(
|
||||
'manual-review',
|
||||
() => markAdminTaskManualReview(resolvedDetail.task.taskId),
|
||||
'任务已转人工处理',
|
||||
`确认将任务 ${resolvedDetail.task.taskNo} 转为人工处理吗?`,
|
||||
)
|
||||
}
|
||||
onCloseTask={() =>
|
||||
runTaskAction(
|
||||
'close',
|
||||
() => closeAdminTask(resolvedDetail.task.taskId),
|
||||
'任务已关闭',
|
||||
`确认关闭任务 ${resolvedDetail.task.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="基础信息">
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="任务 ID">{resolvedDetail.task.taskId}</Descriptions.Item>
|
||||
<Descriptions.Item label="任务号">{resolvedDetail.task.taskNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="平台订单号">
|
||||
{resolvedDetail.task.platformOrderId || resolvedDetail.order?.platformOrderId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务状态">
|
||||
<StatusTag value={resolvedDetail.task.status} kind="task" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源状态">
|
||||
<StatusTag value={resolvedDetail.task.resourceStatus} kind="resource" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="客户步骤">
|
||||
<StatusTag value={resolvedDetail.task.customerStatus} kind="customer" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品">
|
||||
{resolvedDetail.orderItem?.skuName || resolvedDetail.task.skuName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">
|
||||
{resolvedDetail.task.roleName || '-'} {resolvedDetail.task.roleId ? `(${resolvedDetail.task.roleId})` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">
|
||||
{formatAdminDateTime(resolvedDetail.task.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最近错误" span={3}>
|
||||
{resolvedDetail.task.lastError || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{resolvedDetail.claimToken ? (
|
||||
<Card title="领取链接">
|
||||
<Space direction="vertical" size={8} className="full-width">
|
||||
{claimUrl ? (
|
||||
<Typography.Text copyable={{ text: claimUrl }}>{claimUrl}</Typography.Text>
|
||||
) : claimLinkInvalid ? (
|
||||
<Typography.Text type="secondary">领取链接已失效</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无可用领取链接</Typography.Text>
|
||||
)}
|
||||
<Space wrap>
|
||||
<StatusTag value={resolvedDetail.claimToken.status} />
|
||||
<Typography.Text type="secondary">
|
||||
到期:{formatAdminDateTime(resolvedDetail.claimToken.expiredAt)}
|
||||
</Typography.Text>
|
||||
<Button icon={<CopyOutlined />} disabled={!claimUrl} onClick={copyClaimUrl}>
|
||||
复制链接
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{resolvedDetail.task.executorKey === 'manual_dispatch' ||
|
||||
resolvedDetail.operations.canCompleteManualDispatch ||
|
||||
resolvedDetail.manualDispatch ? (
|
||||
<ManualDispatchPanel
|
||||
detail={resolvedDetail}
|
||||
form={manualDispatchForm}
|
||||
canSubmit={canManageTaskLifecycle && resolvedDetail.operations.canCompleteManualDispatch}
|
||||
actionLoadingKey={actionLoadingKey}
|
||||
onChange={setManualDispatchForm}
|
||||
onSubmit={submitManualDispatch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'cloud',
|
||||
label: '快手 Cloud',
|
||||
children: flow ? (
|
||||
<KuaishouCloudPanel
|
||||
flow={flow}
|
||||
canRefresh={resolvedDetail.operations.canRefreshKuaishouCloudRoleInfo}
|
||||
actionLoadingKey={actionLoadingKey}
|
||||
onRefreshRole={() =>
|
||||
runTaskAction(
|
||||
'refresh-role',
|
||||
() => refreshAdminTaskKuaishouCloudRoleInfo(resolvedDetail.task.taskId),
|
||||
'角色信息已刷新',
|
||||
`确认刷新任务 ${resolvedDetail.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 cloudtentacles 的绑定结果。`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="无快手 Cloud 履约上下文" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resolution',
|
||||
label: '兑换重试',
|
||||
children: resolvedDetail.redeemResolution ? (
|
||||
<RedeemResolutionPanel resolution={resolvedDetail.redeemResolution} />
|
||||
) : (
|
||||
<Empty description="无兑换重试记录" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'screenshot',
|
||||
label: '截图',
|
||||
children: resolvedDetail.screenshotUrl ? (
|
||||
<ScreenshotPanel screenshotPreviewUrl={screenshotPreviewUrl} />
|
||||
) : (
|
||||
<Empty description="当前任务没有截图" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
label: '事件流水',
|
||||
children: (
|
||||
<Table<TaskEvent>
|
||||
rowKey="eventId"
|
||||
pagination={false}
|
||||
columns={eventColumns}
|
||||
dataSource={resolvedDetail.events}
|
||||
scroll={{ x: 760 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
label: '原始数据',
|
||||
children: <JsonPreview value={resolvedDetail} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskActionPanel({
|
||||
detail,
|
||||
canManageTaskLifecycle,
|
||||
canCloseTasks,
|
||||
actionLoadingKey,
|
||||
onRetry,
|
||||
onRebindRole,
|
||||
onPrepareKuaishouCloud,
|
||||
onDispatchKuaishouCloud,
|
||||
onReturnKuaishouCloud,
|
||||
onMarkManualReview,
|
||||
onCloseTask,
|
||||
}: {
|
||||
detail: AdminTaskDetail
|
||||
canManageTaskLifecycle: boolean
|
||||
canCloseTasks: boolean
|
||||
actionLoadingKey: string
|
||||
onRetry: () => void
|
||||
onRebindRole: () => void
|
||||
onPrepareKuaishouCloud: () => void
|
||||
onDispatchKuaishouCloud: () => void
|
||||
onReturnKuaishouCloud: () => void
|
||||
onMarkManualReview: () => void
|
||||
onCloseTask: () => void
|
||||
}) {
|
||||
const operations = detail.operations
|
||||
const loading = Boolean(actionLoadingKey)
|
||||
|
||||
return (
|
||||
<Card title="任务操作">
|
||||
<Space wrap>
|
||||
{canManageTaskLifecycle ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={loading || !operations.canRetry}
|
||||
loading={actionLoadingKey === 'retry'}
|
||||
onClick={onRetry}
|
||||
>
|
||||
重试任务
|
||||
</Button>
|
||||
) : null}
|
||||
{operations.canRebindKuaishouCloudRole ? (
|
||||
<Button
|
||||
icon={<SwapOutlined />}
|
||||
disabled={loading}
|
||||
loading={actionLoadingKey === 'rebind-role'}
|
||||
onClick={onRebindRole}
|
||||
>
|
||||
换绑角色
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle && operations.canPrepareKuaishouCloudFulfillment ? (
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ToolOutlined />}
|
||||
disabled={loading}
|
||||
loading={actionLoadingKey === 'prepare-kuaishou-cloud'}
|
||||
onClick={onPrepareKuaishouCloud}
|
||||
>
|
||||
准备绑定资源
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle && operations.canDispatchKuaishouCloudFulfillment ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
disabled={loading}
|
||||
loading={actionLoadingKey === 'dispatch-kuaishou-cloud'}
|
||||
onClick={onDispatchKuaishouCloud}
|
||||
>
|
||||
确认绑定完成并发货
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle && operations.canReturnKuaishouCloudFulfillment ? (
|
||||
<Button
|
||||
icon={<RollbackOutlined />}
|
||||
disabled={loading}
|
||||
loading={actionLoadingKey === 'return-kuaishou-cloud'}
|
||||
onClick={onReturnKuaishouCloud}
|
||||
>
|
||||
退还号码
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle ? (
|
||||
<Button
|
||||
icon={<UserSwitchOutlined />}
|
||||
disabled={loading || !operations.canMarkManualReview}
|
||||
loading={actionLoadingKey === 'manual-review'}
|
||||
onClick={onMarkManualReview}
|
||||
>
|
||||
转人工
|
||||
</Button>
|
||||
) : null}
|
||||
{canCloseTasks ? (
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
disabled={loading || !operations.canClose}
|
||||
loading={actionLoadingKey === 'close'}
|
||||
onClick={onCloseTask}
|
||||
>
|
||||
关闭任务
|
||||
</Button>
|
||||
) : null}
|
||||
{!canManageTaskLifecycle && !canCloseTasks && !operations.canRebindKuaishouCloudRole ? (
|
||||
<Typography.Text type="secondary">当前账号没有可执行的任务操作</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{loading ? (
|
||||
<Typography.Text className="task-action-hint" type="secondary">
|
||||
操作执行中,请稍候。
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ManualDispatchPanel({
|
||||
detail,
|
||||
form,
|
||||
canSubmit,
|
||||
actionLoadingKey,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
detail: AdminTaskDetail
|
||||
form: ManualDispatchForm
|
||||
canSubmit: boolean
|
||||
actionLoadingKey: string
|
||||
onChange: (form: ManualDispatchForm) => void
|
||||
onSubmit: (outcome: 'delivered' | 'failed') => void
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
title="人工履约"
|
||||
extra={
|
||||
canSubmit ? (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
disabled={Boolean(actionLoadingKey)}
|
||||
loading={actionLoadingKey === 'manual-delivered'}
|
||||
onClick={() => onSubmit('delivered')}
|
||||
>
|
||||
人工完成
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
disabled={Boolean(actionLoadingKey)}
|
||||
loading={actionLoadingKey === 'manual-failed'}
|
||||
onClick={() => onSubmit('failed')}
|
||||
>
|
||||
履约失败
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="manual-dispatch-grid">
|
||||
<div>
|
||||
<Typography.Text type="secondary">履约单号 / 回执</Typography.Text>
|
||||
<Input
|
||||
value={form.deliveryReference}
|
||||
disabled={!canSubmit || Boolean(actionLoadingKey)}
|
||||
placeholder="例如快递单号、平台消息回执号"
|
||||
onChange={(event) => onChange({ ...form, deliveryReference: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">发放凭据</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={form.deliveredCredential}
|
||||
disabled={!canSubmit || Boolean(actionLoadingKey)}
|
||||
placeholder="可填写人工发送的卡密、链接或关键信息"
|
||||
rows={3}
|
||||
onChange={(event) => onChange({ ...form, deliveredCredential: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">处理备注</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={form.resultMessage}
|
||||
disabled={!canSubmit || Boolean(actionLoadingKey)}
|
||||
placeholder="说明实际履约结果,失败时建议写清原因"
|
||||
rows={3}
|
||||
onChange={(event) => onChange({ ...form, resultMessage: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type="info"
|
||||
showIcon
|
||||
message="回写后会直接更新任务终态,不再走旧式领取链接流程。"
|
||||
/>
|
||||
|
||||
{detail.manualDispatch ? (
|
||||
<Descriptions
|
||||
className="platform-section-gap"
|
||||
column={{ xs: 1, md: 2, xl: 3 }}
|
||||
bordered
|
||||
size="small"
|
||||
>
|
||||
<Descriptions.Item label="处理结果">
|
||||
<StatusTag value={detail.manualDispatch.outcome || detail.task.deliveryStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="处理时间">
|
||||
{formatAdminDateTime(detail.manualDispatch.completedAt)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="处理人">
|
||||
{detail.manualDispatch.completedBy?.username || '-'} /{' '}
|
||||
{detail.manualDispatch.completedBy?.role || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="履约单号">
|
||||
{detail.manualDispatch.deliveryReference || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发放凭据">
|
||||
{detail.manualDispatch.deliveredCredential || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">
|
||||
{detail.manualDispatch.resultMessage || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function KuaishouCloudPanel({
|
||||
flow,
|
||||
canRefresh,
|
||||
actionLoadingKey,
|
||||
onRefreshRole,
|
||||
}: {
|
||||
flow: NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
|
||||
canRefresh: boolean
|
||||
actionLoadingKey: string
|
||||
onRefreshRole: () => void
|
||||
}) {
|
||||
const roleInfoEntries = flow.role.rawInfo
|
||||
? Object.entries(flow.role.rawInfo).filter(
|
||||
([, value]) => value !== null && value !== undefined && String(value).trim() !== '',
|
||||
)
|
||||
: []
|
||||
const checklist = [
|
||||
{
|
||||
key: 'ticket',
|
||||
label: '核销码校验',
|
||||
status: flow.ticket.status || 'pending',
|
||||
detail: flow.ticket.verifiedAt
|
||||
? `已于 ${formatAdminDateTime(flow.ticket.verifiedAt)} 校验`
|
||||
: '等待客户提交并校验核销码',
|
||||
},
|
||||
{
|
||||
key: 'bind',
|
||||
label: '绑定资源',
|
||||
status: flow.binding.prepareStatus || 'pending',
|
||||
detail: flow.binding.bindPreparedAt
|
||||
? `绑定资源已准备,VN ${flow.binding.vnId || '-'}`
|
||||
: '等待准备 Cloud 绑定资源',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色识别',
|
||||
status: flow.role.status || 'pending',
|
||||
detail:
|
||||
flow.role.name || flow.role.rid
|
||||
? `${flow.role.name || '-'} / ${flow.role.rid || '-'}`
|
||||
: '客户绑定后刷新角色信息',
|
||||
},
|
||||
{
|
||||
key: 'dispatch',
|
||||
label: '发货执行',
|
||||
status: flow.dispatch.status || 'pending',
|
||||
detail: flow.dispatch.dispatchAt
|
||||
? `已于 ${formatAdminDateTime(flow.dispatch.dispatchAt)} 发货`
|
||||
: '等待客服确认绑定并发货',
|
||||
},
|
||||
{
|
||||
key: 'return',
|
||||
label: '退还号码',
|
||||
status: flow.returnNumber.status || 'pending',
|
||||
detail: flow.returnNumber.returnedAt
|
||||
? `已于 ${formatAdminDateTime(flow.returnNumber.returnedAt)} 退号`
|
||||
: '发货后执行退还号码',
|
||||
},
|
||||
{
|
||||
key: 'consume',
|
||||
label: '快手核销',
|
||||
status: flow.consume.status || 'pending',
|
||||
detail: flow.consume.consumedAt
|
||||
? `已于 ${formatAdminDateTime(flow.consume.consumedAt)} 完成核销`
|
||||
: flow.consume.errorMessage || '等待退号后核销收口',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="kuaishou-cloud-stack">
|
||||
<Card
|
||||
title="快手 Cloud 履约"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={Boolean(actionLoadingKey) || !canRefresh}
|
||||
loading={actionLoadingKey === 'refresh-role'}
|
||||
onClick={onRefreshRole}
|
||||
>
|
||||
刷新角色
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
||||
<Descriptions.Item label="来源账号">
|
||||
{flow.binding.resolvedSourceLabel || flow.binding.resolvedSourceKey || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="虚拟号">
|
||||
{flow.binding.vnPhone || flow.binding.vnId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定状态">
|
||||
<StatusTag value={flow.binding.prepareStatus} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="角色">
|
||||
{flow.role.name || '-'} {flow.role.rid ? `(${flow.role.rid})` : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卡券">
|
||||
<StatusTag value={flow.ticket.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发货">
|
||||
<StatusTag value={flow.dispatch.status} kind="delivery" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销">
|
||||
<StatusTag value={flow.consume.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="退号">
|
||||
<StatusTag value={flow.returnNumber.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定链接" span={3}>
|
||||
{flow.binding.bindUrl ? (
|
||||
<Typography.Text copyable={{ text: flow.binding.bindUrl }}>
|
||||
{flow.binding.bindUrl}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="流程检查">
|
||||
<div className="task-flow-grid">
|
||||
{checklist.map((item) => (
|
||||
<div key={item.key} className="task-flow-item">
|
||||
<Space>
|
||||
<StatusTag value={item.status} />
|
||||
<Typography.Text strong>{item.label}</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{item.detail}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{roleInfoEntries.length > 0 ? (
|
||||
<Card title="角色原始字段">
|
||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||
{roleInfoEntries.map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={formatKuaishouRoleInfoLabel(key)}>
|
||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{flow.rebind.history.length > 0 ? (
|
||||
<Card title="换绑记录">
|
||||
<Table
|
||||
rowKey={(row) => `${row.attempt}-${row.requestedAt || ''}`}
|
||||
pagination={false}
|
||||
dataSource={flow.rebind.history}
|
||||
scroll={{ x: 860 }}
|
||||
columns={[
|
||||
{ title: '次数', dataIndex: 'attempt', width: 80 },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'requestedAt',
|
||||
width: 180,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (value) => (
|
||||
<Tag color={value === 'success' ? 'green' : value === 'failed' ? 'red' : 'blue'}>
|
||||
{String(value || '-')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '旧角色',
|
||||
render: (_, row) =>
|
||||
`${row.oldBinding.roleName || '-'} / ${row.oldBinding.roleId || '-'}`,
|
||||
},
|
||||
{
|
||||
title: '旧虚拟号',
|
||||
render: (_, row) =>
|
||||
row.oldBinding.vnPhone || row.oldBinding.vnId || row.oldBinding.vnKey || '-',
|
||||
},
|
||||
{
|
||||
title: '异常',
|
||||
dataIndex: 'errorMessage',
|
||||
render: (value) => value || '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RedeemResolutionPanel({
|
||||
resolution,
|
||||
}: {
|
||||
resolution: NonNullable<AdminTaskDetail['redeemResolution']>
|
||||
}) {
|
||||
return (
|
||||
<Card title="兑换重试链路">
|
||||
<Descriptions column={{ xs: 1, md: 2, xl: 4 }} bordered size="small">
|
||||
<Descriptions.Item label="处理结果">
|
||||
{formatRedeemResolutionStatus(resolution.status)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="任务状态">{resolution.taskStatus || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="替换次数">{resolution.replacementCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">
|
||||
{formatAdminDateTime(resolution.finishedAt)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Table
|
||||
className="platform-section-gap"
|
||||
rowKey={(row) => `${row.attempt}-${row.codeMasked}`}
|
||||
pagination={false}
|
||||
dataSource={resolution.attempts}
|
||||
scroll={{ x: 760 }}
|
||||
columns={[
|
||||
{ title: '次数', dataIndex: 'attempt', width: 80 },
|
||||
{ title: '凭据', dataIndex: 'codeMasked', minWidth: 160 },
|
||||
{ title: '类型', dataIndex: 'credentialType', minWidth: 120 },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'outcome',
|
||||
minWidth: 160,
|
||||
render: (value) => formatRedeemOutcomeLabel(value),
|
||||
},
|
||||
{ title: '代码', dataIndex: 'resultCode', minWidth: 120 },
|
||||
{ title: '说明', dataIndex: 'resultMessage', minWidth: 220 },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ScreenshotPanel({ screenshotPreviewUrl }: { screenshotPreviewUrl: string }) {
|
||||
return (
|
||||
<Card title="任务截图">
|
||||
{screenshotPreviewUrl ? (
|
||||
<div className="task-screenshot-preview">
|
||||
<Image src={screenshotPreviewUrl} alt="任务截图" />
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="截图加载中或当前不可读取" />
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function BackButton() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/tasks')}>
|
||||
返回任务
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
SwapOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Alert, App, Button, Card, DatePicker, Form, Input, Select, Space, Table, Typography } from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import {
|
||||
closeAdminTask,
|
||||
fetchAdminTasks,
|
||||
markAdminTaskManualReview,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
retryAdminTask,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminTaskStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type TaskFilterForm = {
|
||||
status?: string
|
||||
platformOrderId?: string
|
||||
taskNo?: string
|
||||
skuCode?: string
|
||||
roleId?: string
|
||||
dateRange?: unknown
|
||||
}
|
||||
|
||||
type TaskListAction = 'retry' | 'rebind_role' | 'manual_review' | 'close'
|
||||
|
||||
export default function AdminTasksPage() {
|
||||
const navigate = useNavigate()
|
||||
const { message, modal } = App.useApp()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const [lastClaimUrl, setLastClaimUrl] = useState('')
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||
const canCloseTasks = hasAdminRole('support')
|
||||
const filters = {
|
||||
status: searchParams.get('status') || '',
|
||||
platformOrderId: searchParams.get('platformOrderId') || '',
|
||||
taskNo: searchParams.get('taskNo') || '',
|
||||
skuCode: searchParams.get('skuCode') || '',
|
||||
roleId: searchParams.get('roleId') || '',
|
||||
dateFrom: searchParams.get('dateFrom') || '',
|
||||
dateTo: searchParams.get('dateTo') || '',
|
||||
}
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
[
|
||||
filters.dateFrom,
|
||||
filters.dateTo,
|
||||
filters.platformOrderId,
|
||||
filters.roleId,
|
||||
filters.skuCode,
|
||||
filters.status,
|
||||
filters.taskNo,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-tasks', queryParams],
|
||||
queryFn: () => fetchAdminTasks(queryParams),
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
const columns: TableColumnsType<AdminTaskListItem> = [
|
||||
{
|
||||
title: '任务 / 订单',
|
||||
minWidth: 230,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Link onClick={() => navigate(`/admin/tasks/${row.taskId}`)}>
|
||||
{row.taskNo}
|
||||
</Typography.Link>
|
||||
<span className="muted">{row.platformOrderId}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
minWidth: 210,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.skuName || '-'}</span>
|
||||
<span className="muted">{row.skuCode || row.executorKey}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
minWidth: 250,
|
||||
render: (_, row) => (
|
||||
<Space size={[0, 6]} wrap>
|
||||
<StatusTag value={row.status} kind="task" />
|
||||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||||
<StatusTag value={row.customerStatus} kind="customer" />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
minWidth: 180,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{row.roleName || '-'}</span>
|
||||
<span className="muted">{row.roleId || row.loginType || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<span>{formatAdminDateTime(row.updatedAt)}</span>
|
||||
<span className="muted">创建 {formatAdminDateTime(row.createdAt)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '异常 / 操作',
|
||||
minWidth: 260,
|
||||
fixed: 'right',
|
||||
render: (_, row) => {
|
||||
const rowLoading = actionLoadingId === row.taskId
|
||||
const actionRunning = actionLoadingId !== null
|
||||
const canRetry =
|
||||
canManageTaskLifecycle &&
|
||||
row.executorKey !== 'manual_dispatch' &&
|
||||
['retry_pending', 'manual_review'].includes(row.status)
|
||||
const canRebind = canRebindListTask(row, canManageTaskLifecycle)
|
||||
const canClose = canCloseTasks && !['redeemed', 'closed'].includes(row.status)
|
||||
const hasActions = canManageTaskLifecycle || canRebind || canClose
|
||||
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text type={row.lastError ? 'danger' : 'secondary'} ellipsis>
|
||||
{row.lastError || '当前无异常'}
|
||||
</Typography.Text>
|
||||
{hasActions ? (
|
||||
<Space size={[6, 6]} wrap>
|
||||
{canManageTaskLifecycle ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
ghost
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={actionRunning || !canRetry}
|
||||
loading={rowLoading}
|
||||
onClick={() => handleActionCommand('retry', row)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
{canRebind ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SwapOutlined />}
|
||||
disabled={actionRunning}
|
||||
loading={rowLoading}
|
||||
onClick={() => handleActionCommand('rebind_role', row)}
|
||||
>
|
||||
换绑
|
||||
</Button>
|
||||
) : null}
|
||||
{canManageTaskLifecycle ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UserSwitchOutlined />}
|
||||
disabled={actionRunning}
|
||||
loading={rowLoading}
|
||||
onClick={() => handleActionCommand('manual_review', row)}
|
||||
>
|
||||
转人工
|
||||
</Button>
|
||||
) : null}
|
||||
{canClose ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
disabled={actionRunning}
|
||||
loading={rowLoading}
|
||||
onClick={() => handleActionCommand('close', row)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
function handleActionCommand(actionKey: TaskListAction, item: AdminTaskListItem) {
|
||||
const actionConfig = resolveTaskListAction(actionKey, item)
|
||||
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: actionConfig.confirmText,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingId(item.taskId)
|
||||
try {
|
||||
const response = await actionConfig.action()
|
||||
if (response.data.claimUrl) {
|
||||
setLastClaimUrl(response.data.claimUrl)
|
||||
}
|
||||
message.success(actionConfig.successMessage)
|
||||
await query.refetch()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resolveTaskListAction(actionKey: TaskListAction, item: AdminTaskListItem): {
|
||||
action: () => Promise<{ data: AdminTaskActionResponse }>
|
||||
confirmText: string
|
||||
successMessage: string
|
||||
} {
|
||||
if (actionKey === 'retry') {
|
||||
return {
|
||||
action: () => retryAdminTask(item.taskId),
|
||||
confirmText: `确认重试任务 ${item.taskNo} 吗?`,
|
||||
successMessage: '任务已重试',
|
||||
}
|
||||
}
|
||||
|
||||
if (actionKey === 'rebind_role') {
|
||||
return {
|
||||
action: () => rebindAdminTaskKuaishouCloudRole(item.taskId),
|
||||
confirmText: `确认为任务 ${item.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
successMessage: '角色换绑资源已准备完成',
|
||||
}
|
||||
}
|
||||
|
||||
if (actionKey === 'manual_review') {
|
||||
return {
|
||||
action: () => markAdminTaskManualReview(item.taskId),
|
||||
confirmText: `确认将任务 ${item.taskNo} 转为人工处理吗?`,
|
||||
successMessage: '任务已转人工处理',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: () => closeAdminTask(item.taskId),
|
||||
confirmText: `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
successMessage: '任务已关闭',
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(values: TaskFilterForm) {
|
||||
const { dateRange, ...restValues } = values
|
||||
const [dateFrom, dateTo] = Array.isArray(dateRange)
|
||||
? dateRange.map((item: { format?: (pattern: string) => string }) =>
|
||||
item?.format ? item.format('YYYY-MM-DD') : '',
|
||||
)
|
||||
: ['', '']
|
||||
|
||||
setSearchParams(
|
||||
cleanParams({
|
||||
...restValues,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: 1,
|
||||
pageSize,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="任务" description="跟踪履约任务状态、客户步骤和资源准备进度。" />
|
||||
|
||||
<Card>
|
||||
<Form<TaskFilterForm>
|
||||
layout="inline"
|
||||
initialValues={{
|
||||
...filters,
|
||||
dateRange:
|
||||
filters.dateFrom && filters.dateTo
|
||||
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
|
||||
: undefined,
|
||||
}}
|
||||
onFinish={applyFilters}
|
||||
className="filter-form"
|
||||
>
|
||||
<Form.Item name="status">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="任务状态"
|
||||
options={adminTaskStatusOptions}
|
||||
style={{ minWidth: 170 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="platformOrderId">
|
||||
<Input allowClear placeholder="平台订单号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="taskNo">
|
||||
<Input allowClear placeholder="任务号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="roleId">
|
||||
<Input allowClear placeholder="角色 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item name="skuCode">
|
||||
<Input allowClear placeholder="商品名 / SKU" />
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{lastClaimUrl ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="最近可用领取链接"
|
||||
description={
|
||||
<Typography.Text copyable={{ text: lastClaimUrl }}>{lastClaimUrl}</Typography.Text>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<Table<AdminTaskListItem>
|
||||
rowKey="taskId"
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
scroll={{ x: 1240 }}
|
||||
pagination={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function canRebindListTask(item: AdminTaskListItem, canManageTaskLifecycle: boolean) {
|
||||
return (
|
||||
canManageTaskLifecycle &&
|
||||
item.executorKey === 'kuaishou_ct_assisted' &&
|
||||
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
|
||||
)
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (normalized) {
|
||||
next.set(key, normalized)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import {
|
||||
DownOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Result,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { MenuProps, TableColumnsType } from 'antd'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import StatusTag from '@/components/admin/StatusTag'
|
||||
import {
|
||||
createAdminUser,
|
||||
fetchAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
updateAdminUserRole,
|
||||
updateAdminUserStatus,
|
||||
} from '@/services/admin'
|
||||
import type { AdminRole, AdminUserListItem } from '@/types/admin'
|
||||
import { getAdminUserId, hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminUserRoleOptions, adminUserStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type UserFilterForm = {
|
||||
username?: string
|
||||
role?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
type CreateUserForm = {
|
||||
username: string
|
||||
password: string
|
||||
role: AdminRole
|
||||
}
|
||||
|
||||
const USER_ROLE_OPTIONS = [
|
||||
{ label: '普通运营', value: 'operator' },
|
||||
{ label: '客服', value: 'support' },
|
||||
{ label: '管理员', value: 'admin' },
|
||||
]
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const isAdmin = hasAdminRole('admin')
|
||||
const currentUserId = getAdminUserId()
|
||||
const queryClient = useQueryClient()
|
||||
const { message, modal } = App.useApp()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [createForm] = Form.useForm<CreateUserForm>()
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
|
||||
const page = Number(searchParams.get('page') || 1) || 1
|
||||
const pageSize = Number(searchParams.get('pageSize') || 20) || 20
|
||||
const filters = {
|
||||
username: searchParams.get('username') || '',
|
||||
role: searchParams.get('role') || '',
|
||||
status: searchParams.get('status') || '',
|
||||
}
|
||||
const queryParams = useMemo(
|
||||
() => ({ page, pageSize, ...filters }),
|
||||
[filters.role, filters.status, filters.username, page, pageSize],
|
||||
)
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-users', queryParams],
|
||||
enabled: isAdmin,
|
||||
queryFn: () => fetchAdminUsers(queryParams),
|
||||
})
|
||||
const data = query.data?.data
|
||||
|
||||
async function reloadUsers() {
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-users'] })
|
||||
}
|
||||
|
||||
async function submitCreate(values: CreateUserForm) {
|
||||
setCreating(true)
|
||||
|
||||
try {
|
||||
await createAdminUser({
|
||||
username: values.username.trim(),
|
||||
password: values.password.trim(),
|
||||
role: values.role,
|
||||
status: 'active',
|
||||
})
|
||||
message.success('后台用户已创建')
|
||||
createForm.resetFields()
|
||||
setSearchParams(cleanParams({ ...filters, page: 1, pageSize }))
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '创建后台用户失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmUpdateRole(item: AdminUserListItem, nextRole: AdminRole) {
|
||||
if (item.role === nextRole) return
|
||||
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: `确认将 ${item.username} 调整为${formatRoleLabel(nextRole)}吗?`,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await updateAdminUserRole(item.userId, { role: nextRole })
|
||||
message.success('用户角色已更新')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '更新用户角色失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function confirmToggleStatus(item: AdminUserListItem) {
|
||||
const nextStatus = item.status === 'active' ? 'disabled' : 'active'
|
||||
|
||||
modal.confirm({
|
||||
title: '确认操作',
|
||||
content: `确认将 ${item.username}${nextStatus === 'active' ? '启用' : '停用'}吗?`,
|
||||
okText: '继续执行',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await updateAdminUserStatus(item.userId, { status: nextStatus })
|
||||
message.success('用户状态已更新')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '更新用户状态失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function openResetPasswordModal(item: AdminUserListItem) {
|
||||
const form = ModalFormStore.create<{ password: string }>()
|
||||
|
||||
modal.confirm({
|
||||
title: '重置密码',
|
||||
content: (
|
||||
<Form
|
||||
layout="vertical"
|
||||
preserve={false}
|
||||
initialValues={{ password: '' }}
|
||||
ref={form.bind}
|
||||
className="modal-form"
|
||||
>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={`请输入 ${item.username} 的新密码`}
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="至少 8 位" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '提交',
|
||||
cancelText: '取消',
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
const values = await form.validate()
|
||||
setActionLoadingId(item.userId)
|
||||
try {
|
||||
await resetAdminUserPassword(item.userId, { password: values.password.trim() })
|
||||
message.success('用户密码已重置')
|
||||
await reloadUsers()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '重置密码失败')
|
||||
} finally {
|
||||
setActionLoadingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function applyFilters(values: UserFilterForm) {
|
||||
setSearchParams(cleanParams({ ...values, page: 1, pageSize }))
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="后台用户" description="管理员可维护后台账号、角色和启停状态。" />
|
||||
<Result status="warning" title="仅管理员可以访问用户管理。" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminUserListItem> = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'userId',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
dataIndex: 'username',
|
||||
minWidth: 150,
|
||||
render: (value, row) => (
|
||||
<Space>
|
||||
<Typography.Text strong>{value}</Typography.Text>
|
||||
{row.userId === currentUserId ? <Tag>当前账号</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 120,
|
||||
render: (value: AdminRole) => formatRoleLabel(value),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 140,
|
||||
render: (value) => <StatusTag value={value} />,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 170,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 310,
|
||||
render: (_, row) => {
|
||||
const isCurrent = row.userId === currentUserId
|
||||
const roleMenuItems: MenuProps['items'] = USER_ROLE_OPTIONS.map((item) => ({
|
||||
key: item.value,
|
||||
label: `设为${item.label}`,
|
||||
disabled: row.role === item.value,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Space wrap>
|
||||
<Dropdown
|
||||
disabled={isCurrent}
|
||||
menu={{
|
||||
items: roleMenuItems,
|
||||
onClick: ({ key }) => confirmUpdateRole(row, key as AdminRole),
|
||||
}}
|
||||
>
|
||||
<Button disabled={isCurrent} loading={actionLoadingId === row.userId}>
|
||||
改角色 <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button
|
||||
disabled={isCurrent}
|
||||
loading={actionLoadingId === row.userId}
|
||||
onClick={() => confirmToggleStatus(row)}
|
||||
>
|
||||
{row.status === 'active' ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={actionLoadingId === row.userId}
|
||||
onClick={() => openResetPasswordModal(row)}
|
||||
>
|
||||
重置密码
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="后台用户"
|
||||
description="管理员可维护后台账号、角色和启停状态,避免继续使用单一口令。"
|
||||
/>
|
||||
|
||||
<Card className="users-action-card">
|
||||
<div className="users-action-row">
|
||||
<section className="users-action-section">
|
||||
<div className="users-action-title">筛选用户</div>
|
||||
<Form<UserFilterForm>
|
||||
layout="inline"
|
||||
initialValues={filters}
|
||||
onFinish={applyFilters}
|
||||
className="filter-form users-action-form"
|
||||
>
|
||||
<Form.Item name="username">
|
||||
<Input allowClear placeholder="账号筛选" className="users-input-md" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="角色"
|
||||
options={adminUserRoleOptions}
|
||||
className="users-select-sm"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
options={adminUserStatusOptions}
|
||||
className="users-select-sm"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<section className="users-action-section">
|
||||
<div className="users-action-title">新增用户</div>
|
||||
<Form<CreateUserForm>
|
||||
form={createForm}
|
||||
layout="inline"
|
||||
initialValues={{ role: 'operator' }}
|
||||
onFinish={submitCreate}
|
||||
className="filter-form users-action-form"
|
||||
>
|
||||
<Form.Item name="username" rules={[{ required: true, message: '请输入新账号' }]}>
|
||||
<Input placeholder="新账号" className="users-input-md" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[
|
||||
{ required: true, message: '请输入新密码' },
|
||||
{ min: 8, message: '密码至少 8 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password placeholder="新密码,至少 8 位" className="users-input-lg" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" rules={[{ required: true, message: '请选择角色' }]}>
|
||||
<Select options={USER_ROLE_OPTIONS} className="users-select-sm" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<PlusOutlined />} loading={creating}>
|
||||
新增用户
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{query.error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={query.error instanceof Error ? query.error.message : '读取后台用户失败'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
title="用户列表"
|
||||
extra={<Typography.Text type="secondary">共 {data?.pagination.total || 0} 个账号</Typography.Text>}
|
||||
>
|
||||
<Table<AdminUserListItem>
|
||||
rowKey="userId"
|
||||
loading={query.isLoading || query.isFetching}
|
||||
columns={columns}
|
||||
dataSource={data?.items || []}
|
||||
locale={{ emptyText: <Empty description="暂无用户" /> }}
|
||||
scroll={{ x: 1060 }}
|
||||
pagination={{
|
||||
current: data?.pagination.page || page,
|
||||
pageSize: data?.pagination.pageSize || pageSize,
|
||||
total: data?.pagination.total || 0,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (nextPage, nextPageSize) => {
|
||||
setSearchParams(cleanParams({ ...filters, page: nextPage, pageSize: nextPageSize }))
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRoleLabel(role: AdminRole) {
|
||||
if (role === 'admin') return '管理员'
|
||||
if (role === 'support') return '客服'
|
||||
return '普通运营'
|
||||
}
|
||||
|
||||
function cleanParams(params: Record<string, unknown>) {
|
||||
const next = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? '').trim()
|
||||
if (normalized) {
|
||||
next.set(key, normalized)
|
||||
}
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
class ModalFormStore<T extends object> {
|
||||
private form: ReturnType<typeof Form.useForm<T>>[0] | null = null
|
||||
|
||||
static create<T extends object>() {
|
||||
return new ModalFormStore<T>()
|
||||
}
|
||||
|
||||
bind = (instance: unknown) => {
|
||||
this.form = instance as ReturnType<typeof Form.useForm<T>>[0] | null
|
||||
}
|
||||
|
||||
async validate(): Promise<T> {
|
||||
if (!this.form) {
|
||||
return {} as T
|
||||
}
|
||||
|
||||
return this.form.validateFields()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminCloudtentaclesOverrideRules,
|
||||
fetchAdminCloudtentaclesSkuList,
|
||||
fetchAdminCloudtentaclesSourceConfig,
|
||||
fetchAdminKuaishouFeifeiConfig,
|
||||
matchAdminKuaishouFeifeiProduct,
|
||||
saveAdminCloudtentaclesOverrideRules,
|
||||
syncAdminKuaishouFeifeiProducts,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminCloudtentaclesOverrideDeliveryItem,
|
||||
AdminCloudtentaclesOverrideRule,
|
||||
AdminCloudtentaclesSessionItem,
|
||||
AdminCloudtentaclesSessionsMap,
|
||||
AdminCloudtentaclesSkuItem,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
AdminKuaishouFeifeiConfigResponse,
|
||||
AdminKuaishouFeifeiMatchResult,
|
||||
AdminKuaishouFeifeiProductRule,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
type EditableOverrideRule = Omit<AdminCloudtentaclesOverrideRule, 'normalizedProductName'> & {
|
||||
normalizedProductName?: string
|
||||
}
|
||||
|
||||
type SourceRow = AdminCloudtentaclesSourceItem & {
|
||||
session: AdminCloudtentaclesSessionItem | null
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
export default function AdminPlatformFulfillmentPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
title="履约配置"
|
||||
description="维护 91 卡券商品到 kuaishou-feifei 与 cloudtentacles 的履约映射规则。"
|
||||
/>
|
||||
|
||||
{!hasAdminRole('admin') ? (
|
||||
<Card>
|
||||
<Empty description="仅管理员可以查看履约配置" />
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs
|
||||
className="platform-tabs"
|
||||
destroyOnHidden={false}
|
||||
items={[
|
||||
{
|
||||
key: 'kuaishou-feifei',
|
||||
label: 'kuaishou-feifei',
|
||||
children: <KuaishouFeifeiFulfillmentPanel />,
|
||||
},
|
||||
{
|
||||
key: 'kuaishou-cloud',
|
||||
label: 'kuaishou-cloud',
|
||||
children: <KuaishouCloudFulfillmentPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function KuaishouFeifeiFulfillmentPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [matching, setMatching] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [filePath, setFilePath] = useState('')
|
||||
const [rules, setRules] = useState<AdminKuaishouFeifeiProductRule[]>([])
|
||||
const [matchInput, setMatchInput] = useState('')
|
||||
const [matchResult, setMatchResult] = useState<AdminKuaishouFeifeiMatchResult | null>(null)
|
||||
const [lastSyncText, setLastSyncText] = useState('')
|
||||
|
||||
const enabledRuleCount = rules.filter((rule) => rule.enabled !== false).length
|
||||
const mappingCount = rules.filter(
|
||||
(rule) => rule.productName.trim() && rule.productCode.trim(),
|
||||
).length
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig()
|
||||
}, [])
|
||||
|
||||
async function loadConfig() {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetchAdminKuaishouFeifeiConfig()
|
||||
hydrateConfig(response.data)
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-feifei 履约映射失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncProducts() {
|
||||
setSyncing(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await syncAdminKuaishouFeifeiProducts({ status: 'on_sale' })
|
||||
hydrateConfig(response.data)
|
||||
const text = `商品 ${response.data.sync.productCount} 个,映射 ${response.data.sync.ruleCount} 条`
|
||||
setLastSyncText(text)
|
||||
showSuccess(`kuaishou-feifei 商品映射已同步,${text}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '同步 kuaishou-feifei 商品映射失败'
|
||||
setErrorMessage(message)
|
||||
showError(message)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function previewMatch() {
|
||||
const productName = matchInput.trim()
|
||||
if (!productName) {
|
||||
showError('请输入 91 商品名')
|
||||
return
|
||||
}
|
||||
|
||||
setMatching(true)
|
||||
setMatchResult(null)
|
||||
|
||||
try {
|
||||
const response = await matchAdminKuaishouFeifeiProduct(productName)
|
||||
setMatchResult(response.data)
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '预览 kuaishou-feifei 映射失败')
|
||||
} finally {
|
||||
setMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateConfig(data: AdminKuaishouFeifeiConfigResponse) {
|
||||
setFilePath(data.filePath || '')
|
||||
setRules(
|
||||
Array.isArray(data.source.productRules)
|
||||
? data.source.productRules.map((rule) => normalizeFeifeiRule(rule))
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AdminKuaishouFeifeiProductRule> = [
|
||||
{
|
||||
title: 'feifei 商品名',
|
||||
dataIndex: 'productName',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis>
|
||||
{row.productName || row.skuName || '-'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{row.normalizedProductName || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: 'product_code', dataIndex: 'productCode', minWidth: 150 },
|
||||
{ title: '展示名称', dataIndex: 'skuName', minWidth: 220 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
render: (_, row) => (
|
||||
<Tag color={row.enabled !== false ? 'green' : 'default'}>
|
||||
{row.enabled !== false ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '来源', dataIndex: 'notes', minWidth: 180 },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return <Spin />
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<div className="metric-grid four">
|
||||
<MetricCard label="名字映射" value={String(mappingCount)} detail={`启用 ${enabledRuleCount} 条`} />
|
||||
<MetricCard label="匹配方式" value="同名" detail="使用 91 商品名规范化匹配" />
|
||||
<MetricCard
|
||||
label="配置文件"
|
||||
value={filePath ? 'GUI' : '默认'}
|
||||
detail={filePath || 'data/kuaishou-feifei-config.json'}
|
||||
/>
|
||||
<Card>
|
||||
<Space wrap>
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={loadConfig}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} loading={syncing} onClick={syncProducts}>
|
||||
同步商品映射
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="商品名字映射"
|
||||
extra={<Typography.Text type="secondary">{lastSyncText || 'feifei 商品名会按规范化名字匹配 91 商品名。'}</Typography.Text>}
|
||||
>
|
||||
<Space.Compact className="full-width">
|
||||
<Input
|
||||
allowClear
|
||||
value={matchInput}
|
||||
placeholder="91 商品名,例如 套装-Alan Walker"
|
||||
onChange={(event) => setMatchInput(event.target.value)}
|
||||
onPressEnter={previewMatch}
|
||||
/>
|
||||
<Button icon={<SearchOutlined />} loading={matching} onClick={previewMatch}>
|
||||
预览命中
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{matchResult ? (
|
||||
<Alert
|
||||
className="platform-section-gap"
|
||||
type={matchResult.matched ? 'success' : 'warning'}
|
||||
showIcon
|
||||
message={matchResult.matched ? '已命中' : '未命中'}
|
||||
description={
|
||||
matchResult.match
|
||||
? `${matchResult.match.productName} -> ${matchResult.match.productCode}`
|
||||
: matchResult.normalizedProductName || matchResult.productName
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Table<AdminKuaishouFeifeiProductRule>
|
||||
rowKey={(row) => row.id || `${row.productName}-${row.productCode}`}
|
||||
columns={columns}
|
||||
dataSource={rules}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
scroll={{ x: 920, y: 520 }}
|
||||
locale={{ emptyText: '暂无 kuaishou-feifei 商品映射' }}
|
||||
className="platform-section-gap"
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function KuaishouCloudFulfillmentPanel() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [skuLoading, setSkuLoading] = useState(false)
|
||||
const [overrideSaving, setOverrideSaving] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [skuErrorMessage, setSkuErrorMessage] = useState('')
|
||||
const [overrideErrorMessage, setOverrideErrorMessage] = useState('')
|
||||
const [selectedSourceKey, setSelectedSourceKey] = useState('')
|
||||
const [sources, setSources] = useState<AdminCloudtentaclesSourceItem[]>([])
|
||||
const [sessions, setSessions] = useState<AdminCloudtentaclesSessionsMap>({})
|
||||
const [skuItems, setSkuItems] = useState<AdminCloudtentaclesSkuItem[]>([])
|
||||
const [overrideEnabled, setOverrideEnabled] = useState(true)
|
||||
const [overrideRules, setOverrideRules] = useState<EditableOverrideRule[]>([])
|
||||
const [matchInput, setMatchInput] = useState('')
|
||||
|
||||
const sourceRows = useMemo(
|
||||
() =>
|
||||
sources.map((source) => {
|
||||
const session = sessions[source.key] || null
|
||||
return {
|
||||
...source,
|
||||
session,
|
||||
ready: source.enabled !== false && Boolean(session?.hasToken),
|
||||
}
|
||||
}),
|
||||
[sessions, sources],
|
||||
)
|
||||
const readySources = sourceRows.filter((source) => source.ready)
|
||||
const selectedSource = sourceRows.find((source) => source.key === selectedSourceKey) || null
|
||||
const matchedSku = useMemo(() => {
|
||||
const normalizedInput = normalizeMatchName(matchInput)
|
||||
if (!normalizedInput) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
skuItems.find((item) => String(item.name || '').trim() === matchInput.trim()) ||
|
||||
skuItems.find((item) => normalizeMatchName(item.name) === normalizedInput) ||
|
||||
null
|
||||
)
|
||||
}, [matchInput, skuItems])
|
||||
const metrics = {
|
||||
sourceCount: sources.length,
|
||||
readySourceCount: readySources.length,
|
||||
skuCount: skuItems.length,
|
||||
inventoryTotal: skuItems.reduce((sum, item) => sum + Math.max(0, Number(item.inventory || 0)), 0),
|
||||
overrideRuleCount: overrideRules.length,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadPage()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSourceKey) {
|
||||
void loadSkuList(selectedSourceKey)
|
||||
} else {
|
||||
setSkuItems([])
|
||||
}
|
||||
}, [selectedSourceKey])
|
||||
|
||||
async function loadPage() {
|
||||
setLoading(true)
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const [sourceResponse, overrideResponse] = await Promise.all([
|
||||
fetchAdminCloudtentaclesSourceConfig(),
|
||||
fetchAdminCloudtentaclesOverrideRules(),
|
||||
])
|
||||
const nextSources = Array.isArray(sourceResponse.data.sources) ? sourceResponse.data.sources : []
|
||||
const nextSessions = sourceResponse.data.sessions || {}
|
||||
const nextRows = nextSources.map((source) => {
|
||||
const session = nextSessions[source.key] || null
|
||||
return {
|
||||
...source,
|
||||
session,
|
||||
ready: source.enabled !== false && Boolean(session?.hasToken),
|
||||
}
|
||||
})
|
||||
|
||||
setSources(nextSources)
|
||||
setSessions(nextSessions)
|
||||
setOverrideEnabled(overrideResponse.data.enabled !== false)
|
||||
setOverrideRules(
|
||||
Array.isArray(overrideResponse.data.rules)
|
||||
? overrideResponse.data.rules.map((rule) => normalizeEditableRule(rule))
|
||||
: [],
|
||||
)
|
||||
setSelectedSourceKey(resolveDefaultSourceKey(nextRows, selectedSourceKey))
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 履约配置失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkuList(sourceKey = selectedSourceKey) {
|
||||
if (!sourceKey) {
|
||||
setSkuItems([])
|
||||
return
|
||||
}
|
||||
|
||||
setSkuLoading(true)
|
||||
setSkuErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesSkuList({ sourceKey })
|
||||
setSkuItems(Array.isArray(response.data.items) ? response.data.items : [])
|
||||
} catch (error) {
|
||||
setSkuItems([])
|
||||
setSkuErrorMessage(error instanceof Error ? error.message : '读取 cloudtentacles 商品失败')
|
||||
} finally {
|
||||
setSkuLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOverrideRules() {
|
||||
setOverrideSaving(true)
|
||||
setOverrideErrorMessage('')
|
||||
|
||||
try {
|
||||
const response = await saveAdminCloudtentaclesOverrideRules({
|
||||
enabled: overrideEnabled,
|
||||
rules: overrideRules.map((rule) => ({
|
||||
id: rule.id,
|
||||
enabled: rule.enabled,
|
||||
productName: rule.productName,
|
||||
sourceKey: rule.sourceKey,
|
||||
deliveryItems: rule.deliveryItems,
|
||||
notes: rule.notes,
|
||||
})),
|
||||
})
|
||||
setOverrideEnabled(response.data.enabled !== false)
|
||||
setOverrideRules(
|
||||
Array.isArray(response.data.rules)
|
||||
? response.data.rules.map((rule) => normalizeEditableRule(rule))
|
||||
: [],
|
||||
)
|
||||
showSuccess(`商品覆盖规则已保存,共 ${response.data.rules.length} 条`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '保存 cloudtentacles 覆盖规则失败'
|
||||
setOverrideErrorMessage(message)
|
||||
showError(message)
|
||||
} finally {
|
||||
setOverrideSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function addOverrideRule() {
|
||||
setOverrideRules((current) => [
|
||||
{
|
||||
id: createRuleId(),
|
||||
enabled: true,
|
||||
productName: matchInput.trim(),
|
||||
normalizedProductName: normalizeMatchName(matchInput),
|
||||
sourceKey: selectedSourceKey,
|
||||
deliveryItems: [createDeliveryItem(skuItems)],
|
||||
notes: '',
|
||||
},
|
||||
...current,
|
||||
])
|
||||
}
|
||||
|
||||
function updateRule(index: number, patch: Partial<EditableOverrideRule>) {
|
||||
setOverrideRules((current) =>
|
||||
current.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, ...patch } : rule)),
|
||||
)
|
||||
}
|
||||
|
||||
function updateDeliveryItem(ruleIndex: number, itemIndex: number, patch: Partial<AdminCloudtentaclesOverrideDeliveryItem>) {
|
||||
setOverrideRules((current) =>
|
||||
current.map((rule, currentRuleIndex) => {
|
||||
if (currentRuleIndex !== ruleIndex) {
|
||||
return rule
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
deliveryItems: rule.deliveryItems.map((item, currentItemIndex) =>
|
||||
currentItemIndex === itemIndex ? { ...item, ...patch } : item,
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeOverrideRule(index: number) {
|
||||
setOverrideRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index))
|
||||
}
|
||||
|
||||
function addDeliveryItem(ruleIndex: number) {
|
||||
setOverrideRules((current) =>
|
||||
current.map((rule, currentRuleIndex) =>
|
||||
currentRuleIndex === ruleIndex
|
||||
? { ...rule, deliveryItems: [...rule.deliveryItems, createDeliveryItem(skuItems)] }
|
||||
: rule,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function removeDeliveryItem(ruleIndex: number, itemIndex: number) {
|
||||
setOverrideRules((current) =>
|
||||
current.map((rule, currentRuleIndex) => {
|
||||
if (currentRuleIndex !== ruleIndex) {
|
||||
return rule
|
||||
}
|
||||
|
||||
const nextItems = rule.deliveryItems.filter((_, currentItemIndex) => currentItemIndex !== itemIndex)
|
||||
return {
|
||||
...rule,
|
||||
deliveryItems: nextItems.length ? nextItems : [createDeliveryItem(skuItems)],
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function handleDeliverySkuChange(ruleIndex: number, itemIndex: number, skuId: number) {
|
||||
const sku = skuItems.find((item) => Number(item.id || 0) === Number(skuId || 0))
|
||||
updateDeliveryItem(ruleIndex, itemIndex, {
|
||||
cloudSkuId: Number(skuId || 0) || 0,
|
||||
cloudSkuName: String(sku?.name || '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Spin />
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
{errorMessage ? <Alert type="error" showIcon message={errorMessage} /> : null}
|
||||
|
||||
<div className="metric-grid five">
|
||||
<MetricCard label="履约账号" value={`${metrics.readySourceCount} / ${metrics.sourceCount}`} />
|
||||
<MetricCard label="可发商品" value={String(metrics.skuCount)} />
|
||||
<MetricCard label="总库存" value={String(metrics.inventoryTotal)} />
|
||||
<MetricCard label="覆盖规则" value={String(metrics.overrideRuleCount)} />
|
||||
<Card>
|
||||
<Button icon={<ReloadOutlined />} loading={loading || skuLoading} onClick={loadPage}>
|
||||
刷新
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="platform-split-layout">
|
||||
<Card title="cloudtentacles 账号" className="platform-side-card">
|
||||
{sourceRows.length === 0 ? (
|
||||
<Empty description="暂无账号,请先到平台配置登录。" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width">
|
||||
{sourceRows.map((source) => (
|
||||
<button
|
||||
key={source.key}
|
||||
type="button"
|
||||
className={
|
||||
source.key === selectedSourceKey
|
||||
? 'platform-select-card active'
|
||||
: 'platform-select-card'
|
||||
}
|
||||
onClick={() => setSelectedSourceKey(source.key)}
|
||||
>
|
||||
<span>
|
||||
<strong>{formatSourceLabel(source)}</strong>
|
||||
<small>{source.username || source.key}</small>
|
||||
</span>
|
||||
<Tag color={source.ready ? 'green' : source.enabled === false ? 'default' : 'orange'}>
|
||||
{source.ready ? '可用' : source.enabled === false ? '已停用' : '未登录'}
|
||||
</Tag>
|
||||
</button>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<section className="platform-panel-stack">
|
||||
<Card
|
||||
title="匹配状态与覆盖规则"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Typography.Text type="secondary">
|
||||
当前账号:{selectedSource ? formatSourceLabel(selectedSource) : '-'}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={skuLoading}
|
||||
disabled={!selectedSourceKey}
|
||||
onClick={() => loadSkuList()}
|
||||
>
|
||||
刷新商品
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{skuErrorMessage ? (
|
||||
<Alert type="error" showIcon message={skuErrorMessage} className="platform-section-gap" />
|
||||
) : null}
|
||||
|
||||
<Space className="full-width" direction="vertical">
|
||||
<Space.Compact className="full-width">
|
||||
<Input
|
||||
allowClear
|
||||
value={matchInput}
|
||||
placeholder="输入 91 卡券 productNo / 商品名字,查看命中的 cloudtentacles 商品"
|
||||
onChange={(event) => setMatchInput(event.target.value)}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addOverrideRule}>
|
||||
生成规则
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
{matchedSku ? (
|
||||
<Tag color="green">命中 #{matchedSku.id} {matchedSku.name}</Tag>
|
||||
) : matchInput.trim() ? (
|
||||
<Tag color="orange">未命中</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="商品覆盖规则"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Switch
|
||||
checked={overrideEnabled}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
onChange={setOverrideEnabled}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addOverrideRule}>
|
||||
新增规则
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={overrideSaving}
|
||||
onClick={saveOverrideRules}
|
||||
>
|
||||
保存规则
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{overrideErrorMessage ? (
|
||||
<Alert type="error" showIcon message={overrideErrorMessage} className="platform-section-gap" />
|
||||
) : null}
|
||||
|
||||
{overrideRules.length === 0 ? (
|
||||
<Empty description="暂无覆盖规则" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width" size={12}>
|
||||
{overrideRules.map((rule, ruleIndex) => (
|
||||
<Card
|
||||
key={rule.id}
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={rule.enabled !== false}
|
||||
onChange={(enabled) => updateRule(ruleIndex, { enabled })}
|
||||
/>
|
||||
<span>{rule.productName || '未命名规则'}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeOverrideRule(ruleIndex)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="platform-rule-head">
|
||||
<Input
|
||||
value={rule.productName}
|
||||
placeholder="91 商品名 / productNo"
|
||||
onChange={(event) => updateRule(ruleIndex, { productName: event.target.value })}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
value={rule.sourceKey || undefined}
|
||||
placeholder="全部账号"
|
||||
options={[
|
||||
{ label: '全部账号', value: '' },
|
||||
...sourceRows.map((source) => ({
|
||||
label: formatSourceLabel(source),
|
||||
value: source.key,
|
||||
})),
|
||||
]}
|
||||
onChange={(sourceKey) => {
|
||||
updateRule(ruleIndex, { sourceKey: String(sourceKey || '') })
|
||||
if (sourceKey) {
|
||||
setSelectedSourceKey(String(sourceKey))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" className="full-width platform-section-gap" size={8}>
|
||||
{rule.deliveryItems.map((item, itemIndex) => (
|
||||
<div key={`${rule.id}-${itemIndex}`} className="platform-delivery-row">
|
||||
<Select
|
||||
showSearch
|
||||
value={item.cloudSkuId || undefined}
|
||||
placeholder="选择 cloudtentacles 商品"
|
||||
optionFilterProp="label"
|
||||
options={skuItems.map((sku) => ({
|
||||
label: formatSkuOption(sku),
|
||||
value: sku.id,
|
||||
}))}
|
||||
onChange={(skuId) => handleDeliverySkuChange(ruleIndex, itemIndex, Number(skuId || 0))}
|
||||
/>
|
||||
<Input
|
||||
value={item.cloudSkuName}
|
||||
placeholder="cloudtentacles 商品名"
|
||||
onChange={(event) =>
|
||||
updateDeliveryItem(ruleIndex, itemIndex, {
|
||||
cloudSkuName: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={999}
|
||||
value={item.quantity}
|
||||
onChange={(quantity) =>
|
||||
updateDeliveryItem(ruleIndex, itemIndex, {
|
||||
quantity: Math.max(1, Number(quantity || 1) || 1),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button onClick={() => removeDeliveryItem(ruleIndex, itemIndex)}>删除</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button onClick={() => addDeliveryItem(ruleIndex)}>增加发货商品</Button>
|
||||
</Space>
|
||||
|
||||
<Input
|
||||
className="platform-section-gap"
|
||||
value={rule.notes}
|
||||
placeholder="备注"
|
||||
onChange={(event) => updateRule(ruleIndex, { notes: event.target.value })}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="cloudtentacles 商品列表">
|
||||
<Table<AdminCloudtentaclesSkuItem>
|
||||
rowKey="id"
|
||||
loading={skuLoading}
|
||||
dataSource={skuItems}
|
||||
columns={[
|
||||
{
|
||||
title: 'cloudtentacles 商品',
|
||||
dataIndex: 'name',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis>{row.name || '-'}</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
#{row.id} · {row.description || row.name || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '91 自动匹配名',
|
||||
dataIndex: 'name',
|
||||
minWidth: 220,
|
||||
render: (value) => <code>{String(value || '-')}</code>,
|
||||
},
|
||||
{ title: '价格', dataIndex: 'price', width: 120, render: formatCloudPrice },
|
||||
{ title: '库存', dataIndex: 'inventory', width: 120, sorter: (a, b) => a.inventory - b.inventory },
|
||||
{
|
||||
title: '发货限制',
|
||||
width: 150,
|
||||
render: (_, row) => `${row.buyLimitMin || 1} - ${row.buyLimitMax || 1}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Tag color={Number(row.inventory || 0) > 0 ? 'green' : 'red'}>
|
||||
{Number(row.inventory || 0) > 0 ? '可履约' : '无库存'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true }}
|
||||
scroll={{ x: 1000, y: 420 }}
|
||||
locale={{ emptyText: '当前账号暂无可展示商品' }}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<Card className="metric-card">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
{detail ? <small>{detail}</small> : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeFeifeiRule(
|
||||
rule: Partial<AdminKuaishouFeifeiProductRule>,
|
||||
): AdminKuaishouFeifeiProductRule {
|
||||
const productName = String(rule.productName || '').trim()
|
||||
const productCode = String(rule.productCode || '').trim()
|
||||
|
||||
return {
|
||||
id: String(rule.id || productName || productCode).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
productName,
|
||||
normalizedProductName: String(rule.normalizedProductName || '').trim(),
|
||||
productCode,
|
||||
skuName: String(rule.skuName || productName || productCode).trim(),
|
||||
notes: String(rule.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEditableRule(rule: AdminCloudtentaclesOverrideRule): EditableOverrideRule {
|
||||
return {
|
||||
id: String(rule.id || createRuleId()).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
productName: String(rule.productName || '').trim(),
|
||||
normalizedProductName: String(rule.normalizedProductName || '').trim(),
|
||||
sourceKey: String(rule.sourceKey || '').trim(),
|
||||
deliveryItems:
|
||||
Array.isArray(rule.deliveryItems) && rule.deliveryItems.length > 0
|
||||
? rule.deliveryItems.map((item) => ({
|
||||
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
|
||||
cloudSkuName: String(item.cloudSkuName || '').trim(),
|
||||
quantity: Math.max(1, Number(item.quantity || 1) || 1),
|
||||
}))
|
||||
: [createDeliveryItem([])],
|
||||
notes: String(rule.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function createDeliveryItem(skuItems: AdminCloudtentaclesSkuItem[]): AdminCloudtentaclesOverrideDeliveryItem {
|
||||
const firstSku = skuItems[0]
|
||||
return {
|
||||
cloudSkuId: Number(firstSku?.id || 0) || 0,
|
||||
cloudSkuName: String(firstSku?.name || '').trim(),
|
||||
quantity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultSourceKey(sourceRows: SourceRow[], selectedSourceKey: string) {
|
||||
const current = sourceRows.find((source) => source.key === selectedSourceKey)
|
||||
if (current?.ready) {
|
||||
return current.key
|
||||
}
|
||||
|
||||
return sourceRows.find((source) => source.ready)?.key || sourceRows[0]?.key || ''
|
||||
}
|
||||
|
||||
function normalizeMatchName(value: unknown) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[【】\[\]()()]/g, ' ')
|
||||
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function formatSourceLabel(source: Pick<AdminCloudtentaclesSourceItem, 'key' | 'label' | 'username'>) {
|
||||
return source.label || source.username || source.key
|
||||
}
|
||||
|
||||
function formatCloudPrice(value: unknown) {
|
||||
const price = Number(value)
|
||||
return Number.isFinite(price) ? String(price) : '-'
|
||||
}
|
||||
|
||||
function formatSkuOption(sku: AdminCloudtentaclesSkuItem) {
|
||||
return `#${sku.id} ${sku.name}`
|
||||
}
|
||||
|
||||
function createRuleId() {
|
||||
return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,113 +0,0 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/admin/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/claim/:token',
|
||||
component: () => import('@/views/claim/ClaimView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
redirect: '/admin/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
component: () => import('@/views/admin/auth/AdminLoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/views/admin/layout/AdminLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/admin/dashboard',
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
component: () => import('@/views/admin/dashboard/AdminDashboardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
meta: { allowedRoles: ['admin'] },
|
||||
component: () => import('@/views/admin/users/AdminUsersView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
component: () => import('@/views/admin/orders/AdminOrdersView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'orders/:orderId',
|
||||
component: () => import('@/views/admin/orders/AdminOrderDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'tasks',
|
||||
component: () => import('@/views/admin/tasks/AdminTasksView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'tasks/:taskId',
|
||||
component: () => import('@/views/admin/tasks/AdminTaskDetailView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'cloudtentacles-records',
|
||||
component: () =>
|
||||
import('@/views/admin/cloudtentacles-records/AdminCloudtentaclesRecordsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'platform-shops',
|
||||
meta: { allowedRoles: ['admin'] },
|
||||
component: () => import('@/views/admin/platform-shops/AdminPlatformShopsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'platform-fulfillment',
|
||||
meta: { allowedRoles: ['admin'] },
|
||||
component: () => import('@/views/admin/fulfillment/AdminFulfillmentConfigHubView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
meta: { allowedRoles: ['admin'] },
|
||||
component: () => import('@/views/admin/audit-logs/AdminAuditLogsView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('@/views/NotFoundView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!to.path.startsWith('/admin')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (to.path === '/admin/login') {
|
||||
if (hasAdminSession()) {
|
||||
return '/admin/dashboard'
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (!hasAdminSession()) {
|
||||
return '/admin/login'
|
||||
}
|
||||
|
||||
const allowedRoles = to.matched
|
||||
.map((record) => (record.meta?.allowedRoles as string[] | undefined) || [])
|
||||
.find((roles) => Array.isArray(roles) && roles.length > 0)
|
||||
|
||||
if (allowedRoles && !allowedRoles.includes(getAdminRole())) {
|
||||
return '/admin/dashboard'
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,238 +0,0 @@
|
||||
/* ============================================================
|
||||
管理员配置页面共享结构样式
|
||||
引入: AdminKuaishouCloudFulfillment.css
|
||||
各页面自行覆盖颜色、圆角、主题属性
|
||||
============================================================ */
|
||||
|
||||
/* ---------- 分区标题 ---------- */
|
||||
.section-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.section-title-row h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-title-row p {
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
/* ---------- 头部工具 ---------- */
|
||||
.section-header-tools,
|
||||
.section-mini-stats,
|
||||
.section-action-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-header-tools {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---------- 卡片容器 ---------- */
|
||||
.overview-card,
|
||||
.table-card,
|
||||
.empty-block,
|
||||
.error-copy {
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(86, 108, 138, 0.1);
|
||||
}
|
||||
|
||||
.table-card--dense {
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ---------- 概览文件 & 统计 ---------- */
|
||||
.overview-file {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.overview-file code {
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 14px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.overview-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.overview-stat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.overview-stat span {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.overview-stat strong {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.overview-notes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---------- 分区工具 ---------- */
|
||||
.section-title-row--tight {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---------- 字段块 ---------- */
|
||||
.field-block {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field-block span {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field-block--action {
|
||||
align-content: end;
|
||||
}
|
||||
|
||||
.field-block--action :deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- 文本输入 ---------- */
|
||||
.text-input {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* ---------- 校验 & 错误 ---------- */
|
||||
.validation-banner,
|
||||
.binding-inline-error {
|
||||
margin: 10px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.binding-inline-error {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ---------- 数据表 (用于 el-table,具体单元格样式见各页面 :deep() 规则) ---------- */
|
||||
.data-table--config {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.cell-stack--config {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cell-subtle {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* ---------- 绑定卡片 ---------- */
|
||||
.binding-card {
|
||||
margin-top: 12px;
|
||||
padding: 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.binding-card--invalid {
|
||||
border-color: rgba(220, 38, 38, 0.3);
|
||||
box-shadow: 0 0 0 4px rgba(254, 226, 226, 0.85);
|
||||
}
|
||||
|
||||
.binding-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.binding-summary {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.binding-title-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.binding-subtle {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* ---------- 状态标签 ---------- */
|
||||
.binding-status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 2px 10px;
|
||||
background: #fff1f2;
|
||||
color: #be123c;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.binding-status-chip--done {
|
||||
background: #ecfdf3;
|
||||
color: #027a48;
|
||||
}
|
||||
|
||||
.binding-collapsed-preview {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ---------- 操作栏 ---------- */
|
||||
.binding-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ---------- 工具栏提示 ---------- */
|
||||
.toolbar-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
/* ============================================================
|
||||
管理员列表页共享样式
|
||||
引入: AdminOrdersView, AdminTasksView, AdminUsersView
|
||||
各页面自行覆盖 grid-template-columns 等布局差异
|
||||
============================================================ */
|
||||
|
||||
/* ---------- 页面包裹 ---------- */
|
||||
.list-page {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ---------- 总量标签 ---------- */
|
||||
.total-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(37, 89, 167, 0.14);
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(37, 89, 167, 0.08);
|
||||
color: #2559a7;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- 卡片区域 ---------- */
|
||||
.section-card {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ---------- 筛选网格 ---------- */
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-control {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
min-width: 156px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---------- 摘要条 ---------- */
|
||||
.summary-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 38px;
|
||||
padding: 0;
|
||||
margin-top: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.summary-metric {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-secondary);
|
||||
background: #f8fafc;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-metric strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summary-metric[data-tone='success'] {
|
||||
color: var(--color-success-dark);
|
||||
background: var(--color-success-light);
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
.summary-metric[data-tone='danger'] {
|
||||
color: var(--color-danger-dark);
|
||||
background: var(--color-danger-light);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
.summary-metric[data-tone='primary'] {
|
||||
color: var(--color-primary-dark);
|
||||
background: var(--color-primary-light);
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
.summary-metric[data-tone='warning'] {
|
||||
color: var(--color-warning-dark);
|
||||
background: var(--color-warning-light);
|
||||
border-color: #fed7aa;
|
||||
}
|
||||
.summary-metric[data-tone='muted'] {
|
||||
color: var(--text-secondary);
|
||||
background: #f8fafc;
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
/* ---------- 表格工具栏 ---------- */
|
||||
.table-toolbar {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.table-toolbar strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.table-toolbar span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* ---------- el-table 统一主题变量 ---------- */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: rgba(239, 246, 255, 0.72);
|
||||
--el-table-row-hover-bg-color: rgba(239, 246, 255, 0.72);
|
||||
--el-table-border-color: rgba(86, 108, 138, 0.1);
|
||||
}
|
||||
|
||||
.data-table :deep(.el-table__inner-wrapper::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data-table :deep(th.el-table__cell) {
|
||||
height: 42px;
|
||||
background: var(--el-table-header-bg-color);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ---------- 单元格通用 ---------- */
|
||||
.cell-stack {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cell-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.compact-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
max-width: 240px;
|
||||
padding: 0 9px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #f4f7fb;
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compact-chip--muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cell-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cell-subline {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #475467;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* ---------- ID 链接 ---------- */
|
||||
.id-link {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.id-link:hover {
|
||||
color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* ---------- 详情链接按钮 ---------- */
|
||||
.detail-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-default);
|
||||
color: var(--color-primary-dark);
|
||||
background: var(--color-primary-light);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.detail-link:hover {
|
||||
border-color: var(--border-hover);
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
/* ---------- 操作 & 状态容器 ---------- */
|
||||
.action-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.action-stack--nowrap {
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-stack--vertical {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.action-stack--tight {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-stack :deep(.el-tag) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ---------- 响应式 ---------- */
|
||||
@media (max-width: 1200px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.filter-buttons {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.filter-buttons {
|
||||
width: 100%;
|
||||
}
|
||||
.filter-buttons :deep(.el-button) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
/* ===== Element Plus Global Overrides =====
|
||||
Single place for all Element Plus style normalization.
|
||||
Eliminates the need for scattered :deep() overrides in components.
|
||||
*/
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.el-button {
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--color-primary);
|
||||
--el-button-border-color: var(--color-primary);
|
||||
--el-button-hover-bg-color: var(--color-primary-dark);
|
||||
--el-button-hover-border-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
/* ---- Dialogs ---- */
|
||||
.el-dialog {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* ---- Cards ---- */
|
||||
.el-card {
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ---- Tables ---- */
|
||||
.el-table {
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Tags ---- */
|
||||
.el-tag {
|
||||
border-radius: var(--radius-full);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- Menus ---- */
|
||||
.el-menu {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ---- Inputs & Selects ---- */
|
||||
.el-input .el-input__wrapper,
|
||||
.el-select .el-select__wrapper,
|
||||
.el-date-editor.el-input__wrapper {
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.el-date-editor.el-input__wrapper {
|
||||
border: 0;
|
||||
box-shadow: 0 0 0 1px var(--border-default) inset;
|
||||
}
|
||||
|
||||
.el-input .el-input__wrapper:hover,
|
||||
.el-select .el-select__wrapper:hover,
|
||||
.el-date-editor.el-input__wrapper:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.el-date-editor.el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--border-hover) inset;
|
||||
}
|
||||
|
||||
.el-input.is-focus .el-input__wrapper,
|
||||
.el-select.is-focus .el-select__wrapper,
|
||||
.el-date-editor.el-input__wrapper.is-active {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 1px var(--color-primary) inset;
|
||||
}
|
||||
|
||||
/* ---- Pagination ---- */
|
||||
.el-pagination {
|
||||
justify-content: center;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.el-tabs__header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ---- Switch ---- */
|
||||
.el-switch.is-checked .el-switch__core {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ---- Skeleton ---- */
|
||||
.el-skeleton {
|
||||
--el-skeleton-color: var(--bg-subtle);
|
||||
}
|
||||
|
||||
/* ---- Empty ---- */
|
||||
.el-empty__description {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ---- 通用工具类 ---- */
|
||||
.mt-4 {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.text-muted-xs {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.text-muted-xs.mt-1 {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
.fw-600 {
|
||||
font-weight: 600;
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.ml-2 {
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
.pagination-center {
|
||||
margin-top: var(--space-4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-end;
|
||||
}
|
||||
.card-header--split {
|
||||
align-items: center;
|
||||
}
|
||||
.card-title {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.card-desc {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.filter-col-160 {
|
||||
width: 160px;
|
||||
}
|
||||
+1160
-36
File diff suppressed because it is too large
Load Diff
@@ -1,76 +0,0 @@
|
||||
/* ===== Design Tokens =====
|
||||
Single source of truth for all visual values.
|
||||
Every component/view must reference these variables instead of hardcoded values.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ---- Color Palette ---- */
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-light: #eff6ff;
|
||||
--color-primary-dark: #1d4ed8;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-success-light: #f0fdf4;
|
||||
--color-success-dark: #15803d;
|
||||
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-light: #fffbeb;
|
||||
--color-warning-dark: #b45309;
|
||||
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-light: #fef2f2;
|
||||
--color-danger-dark: #b91c1c;
|
||||
|
||||
/* ---- Surfaces & Backgrounds ---- */
|
||||
--bg-page: #f3f7fc;
|
||||
--bg-surface: rgba(255, 255, 255, 0.94);
|
||||
--bg-sidebar: rgba(255, 255, 255, 0.92);
|
||||
--bg-subtle: rgba(15, 93, 216, 0.06);
|
||||
|
||||
/* ---- Text ---- */
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
/* ---- Borders ---- */
|
||||
--border-default: rgba(86, 108, 138, 0.12);
|
||||
--border-hover: rgba(37, 99, 235, 0.28);
|
||||
|
||||
/* ---- Spacing (4px base) ---- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
/* ---- Border Radius ---- */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 20px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* ---- Shadows ---- */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.07);
|
||||
--shadow-lg: 0 10px 30px rgba(30, 49, 78, 0.08);
|
||||
|
||||
/* ---- Typography ---- */
|
||||
--font-sans: 'PingFang SC', 'SF Pro Display', 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SFMono-Regular', ui-monospace, monospace;
|
||||
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-base: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 24px;
|
||||
--text-3xl: 28px;
|
||||
|
||||
/* ---- Breakpoints (for reference, not used as CSS variables directly) ---- */
|
||||
--bp-lg: 1200px;
|
||||
--bp-md: 900px;
|
||||
--bp-sm: 640px;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<main class="not-found">
|
||||
<div class="card">
|
||||
<p class="code">404</p>
|
||||
<h1>页面不存在</h1>
|
||||
<p>当前快手轻量前端没有对应页面。</p>
|
||||
<RouterLink to="/admin/dashboard">前往后台</RouterLink>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(560px, 100%);
|
||||
padding: 40px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 18px 64px rgba(17, 38, 78, 0.12);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0 16px;
|
||||
font-size: clamp(30px, 5vw, 46px);
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-flex;
|
||||
margin-top: 20px;
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -1,214 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { useAdminListPage } from '@/composables/useAdminListPage'
|
||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import { fetchAdminAuditLogs } from '@/services/admin'
|
||||
import type { AdminAuditLogItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAuditAction, formatAuditTargetType } from '@/utils/admin-display'
|
||||
import { adminAuditActionOptions, adminAuditTargetTypeOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { stringifyDisplayJson } from '@/utils/date-time'
|
||||
|
||||
const actorUsername = ref('')
|
||||
const action = ref('')
|
||||
const targetType = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
|
||||
const isAdmin = hasAdminRole('admin')
|
||||
|
||||
const {
|
||||
loading,
|
||||
errorMessage,
|
||||
items,
|
||||
pagination,
|
||||
loadPage: loadAuditLogs,
|
||||
} = useAdminListPage<AdminAuditLogItem>({
|
||||
defaultErrorMessage: '读取审计日志失败',
|
||||
fetchPage: isAdmin
|
||||
? (page, pageSize) =>
|
||||
fetchAdminAuditLogs({
|
||||
page,
|
||||
pageSize,
|
||||
actorUsername: actorUsername.value.trim(),
|
||||
action: action.value.trim(),
|
||||
targetType: targetType.value.trim(),
|
||||
dateFrom: dateFrom.value.trim(),
|
||||
dateTo: dateTo.value.trim(),
|
||||
})
|
||||
: () =>
|
||||
Promise.resolve({
|
||||
code: 0,
|
||||
msg: '',
|
||||
data: { items: [], pagination: { page: 1, pageSize: 20, total: 0 } },
|
||||
}),
|
||||
})
|
||||
|
||||
function formatPayload(payload: Record<string, unknown>) {
|
||||
const text = stringifyDisplayJson(payload, 0)
|
||||
return text.length > 120 ? `${text.slice(0, 120)}...` : text
|
||||
}
|
||||
|
||||
function resolveTargetLabel(targetType: string, targetId: string) {
|
||||
return `${formatAuditTargetType(targetType)} #${targetId || '-'}`
|
||||
}
|
||||
|
||||
const totalLabel = computed(() => `共 ${pagination.value.total} 条审计记录`)
|
||||
|
||||
function resetFilters() {
|
||||
actorUsername.value = ''
|
||||
action.value = ''
|
||||
targetType.value = ''
|
||||
dateFrom.value = ''
|
||||
dateTo.value = ''
|
||||
void loadAuditLogs(1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAuditLogs(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audit-page list-page">
|
||||
<AdminPageHeader
|
||||
title="操作审计"
|
||||
description="集中查看高风险后台动作,便于排查谁在什么时间改了什么。"
|
||||
>
|
||||
<template #extra>
|
||||
<span class="total-badge">{{ totalLabel }}</span>
|
||||
</template>
|
||||
</AdminPageHeader>
|
||||
|
||||
<!-- 权限检查 -->
|
||||
<el-result v-if="!isAdmin" icon="warning" title="仅管理员可以访问操作审计" />
|
||||
|
||||
<template v-else>
|
||||
<!-- 筛选栏 -->
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">筛选审计日志</span>
|
||||
<span class="card-desc">按操作人、动作类型、目标类型和日期筛选。</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-grid">
|
||||
<el-input
|
||||
v-model="actorUsername"
|
||||
placeholder="操作账号"
|
||||
clearable
|
||||
class="filter-control"
|
||||
@keyup.enter="loadAuditLogs()"
|
||||
/>
|
||||
<el-select v-model="action" placeholder="动作" clearable class="filter-control">
|
||||
<el-option
|
||||
v-for="opt in adminAuditActionOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="targetType" placeholder="目标类型" clearable class="filter-control">
|
||||
<el-option
|
||||
v-for="opt in adminAuditTargetTypeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="filter-control"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="filter-control"
|
||||
/>
|
||||
<div class="filter-buttons">
|
||||
<el-button round @click="resetFilters">重置</el-button>
|
||||
<el-button round type="primary" @click="loadAuditLogs()">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-card shadow="never" class="section-card">
|
||||
<el-table
|
||||
:data="items"
|
||||
stripe
|
||||
size="small"
|
||||
v-loading="loading"
|
||||
element-loading-text="审计日志加载中"
|
||||
class="data-table"
|
||||
>
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{ formatAdminDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actorUsername" label="操作人" width="120" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<template #default="{ row }"><AdminStatusTag :status="row.actorRole" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="动作" width="140">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ formatAuditAction(row.action) }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<strong>{{ resolveTargetLabel(row.targetType, row.targetId) }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="摘要" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="payload-text">{{ formatPayload(row.payload) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty :image-size="60" description="暂无审计记录" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-if="pagination.total > 0"
|
||||
v-model:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
class="pagination-center"
|
||||
@current-change="loadAuditLogs"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '../../../styles/admin-list-pages.css';
|
||||
|
||||
.payload-text {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
word-break: break-all;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,122 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import { loginAdmin } from '@/services/admin'
|
||||
import { setAdminSession } from '@/utils/admin-auth'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
|
||||
async function submitLogin() {
|
||||
if (!username.value.trim() || !password.value.trim()) {
|
||||
showError('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await loginAdmin({
|
||||
username: username.value.trim(),
|
||||
password: password.value.trim(),
|
||||
})
|
||||
|
||||
setAdminSession(response.data.token, response.data.expiresAt, response.data.user)
|
||||
showSuccess('登录成功')
|
||||
await router.replace('/admin/dashboard')
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '后台登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-card">
|
||||
<header class="login-header">
|
||||
<p class="eyebrow">Order Site Admin</p>
|
||||
<h1>运营后台登录</h1>
|
||||
<p>使用账号密码进入后台,并按角色开放不同操作权限。</p>
|
||||
</header>
|
||||
|
||||
<label class="field">
|
||||
<span>账号</span>
|
||||
<el-input v-model="username" placeholder="输入账号" @keyup.enter="submitLogin" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<el-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="输入密码"
|
||||
show-password
|
||||
@keyup.enter="submitLogin"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<el-button :loading="loading" round type="primary" class="submit-btn" @click="submitLogin">
|
||||
登录后台
|
||||
</el-button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(59, 130, 246, 0.08), transparent 26%),
|
||||
linear-gradient(160deg, var(--color-primary-light) 0%, #f7fbff 42%, #edf6f3 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(460px, 100%);
|
||||
padding: 28px;
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
</style>
|
||||
-649
@@ -1,649 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminCloudtentaclesDeliveryRecords,
|
||||
fetchAdminCloudtentaclesRecordSources,
|
||||
} from '@/services/admin'
|
||||
import type { AdminCloudtentaclesDeliveryRecordItem } from '@/types/admin'
|
||||
import { getAdminToken } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type RecordSourceOption = {
|
||||
key: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
hasToken: boolean
|
||||
loggedInAt: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const sourceLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const sourceKey = ref('')
|
||||
const platformOrderId = ref('')
|
||||
const dateRange = ref<[string, string]>(createDefaultDateRange())
|
||||
const page = ref(1)
|
||||
const pageSize = ref(100)
|
||||
const total = ref(0)
|
||||
const items = ref<AdminCloudtentaclesDeliveryRecordItem[]>([])
|
||||
const sourceOptions = ref<RecordSourceOption[]>([])
|
||||
const previewVisible = ref(false)
|
||||
const previewImageUrl = ref('')
|
||||
const previewFileName = ref('')
|
||||
|
||||
const usableSourceOptions = computed(() =>
|
||||
sourceOptions.value.filter((source) => source.enabled && source.hasToken),
|
||||
)
|
||||
|
||||
const currentSourceLabel = computed(() => {
|
||||
const matched = sourceOptions.value.find((source) => source.key === sourceKey.value)
|
||||
return matched?.label || sourceKey.value || '-'
|
||||
})
|
||||
|
||||
async function loadSources() {
|
||||
sourceLoading.value = true
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesRecordSources()
|
||||
sourceOptions.value = response.data.sources || []
|
||||
if (!sourceKey.value) {
|
||||
sourceKey.value = usableSourceOptions.value[0]?.key || sourceOptions.value[0]?.key || ''
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 cloudtentacles 账号失败'
|
||||
} finally {
|
||||
sourceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecords(nextPage = page.value) {
|
||||
const orderKeyword = platformOrderId.value.trim()
|
||||
if (!sourceKey.value && !orderKeyword) {
|
||||
showError('请先选择 cloudtentacles 账号')
|
||||
return
|
||||
}
|
||||
|
||||
const [startDate, endDate] = dateRange.value || []
|
||||
if (!startDate || !endDate) {
|
||||
showError('请选择查询时间范围')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesDeliveryRecords({
|
||||
sourceKey: sourceKey.value || undefined,
|
||||
page: nextPage,
|
||||
size: pageSize.value,
|
||||
startDate,
|
||||
endDate,
|
||||
platformOrderId: orderKeyword || undefined,
|
||||
})
|
||||
if (response.data.sourceKey && sourceKey.value !== response.data.sourceKey) {
|
||||
sourceKey.value = response.data.sourceKey
|
||||
}
|
||||
page.value = response.data.page
|
||||
pageSize.value = response.data.size
|
||||
total.value = response.data.total
|
||||
items.value = response.data.items || []
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '查询发货记录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
platformOrderId.value = ''
|
||||
dateRange.value = createDefaultDateRange()
|
||||
page.value = 1
|
||||
pageSize.value = 100
|
||||
void loadRecords(1)
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
void loadRecords(nextPage)
|
||||
}
|
||||
|
||||
function handlePageSizeChange(nextSize: number) {
|
||||
pageSize.value = nextSize
|
||||
void loadRecords(1)
|
||||
}
|
||||
|
||||
async function generateRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
try {
|
||||
const dataUrl = await drawRecordImage(record)
|
||||
previewImageUrl.value = dataUrl
|
||||
previewFileName.value = buildRecordImageFileName(record)
|
||||
previewVisible.value = true
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '生成发货记录图片失败')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadPreviewImage() {
|
||||
if (!previewImageUrl.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = previewImageUrl.value
|
||||
link.download = previewFileName.value || 'cloudtentacles-delivery-record.png'
|
||||
link.click()
|
||||
showSuccess('发货记录图片已生成')
|
||||
}
|
||||
|
||||
function getStatusLabel(status: number) {
|
||||
if (status === 4) return '已完成'
|
||||
if (status === 1) return '处理中'
|
||||
if (status === 2) return '待处理'
|
||||
if (status === 3) return '失败'
|
||||
return `状态 ${status || '-'}`
|
||||
}
|
||||
|
||||
function getStatusType(status: number) {
|
||||
if (status === 4) return 'success'
|
||||
if (status === 3) return 'danger'
|
||||
if (status === 1) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function getLocalDispatchStatusLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (normalized === 'success') return '本地已发货'
|
||||
if (normalized === 'failed') return '本地发货失败'
|
||||
if (normalized === 'pending') return '本地待发货'
|
||||
return normalized || '-'
|
||||
}
|
||||
|
||||
function getMatchConfidenceLabel(value: string) {
|
||||
if (value === 'high') return '高'
|
||||
if (value === 'medium') return '中'
|
||||
if (value === 'low') return '低'
|
||||
if (value === 'local') return '本地'
|
||||
return ''
|
||||
}
|
||||
|
||||
function createDefaultDateRange(): [string, string] {
|
||||
const end = new Date()
|
||||
const start = new Date(end)
|
||||
start.setDate(start.getDate() - 7)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return [formatLocalDateTime(start), formatLocalDateTime(end)]
|
||||
}
|
||||
|
||||
function formatLocalDateTime(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
|
||||
date.getHours(),
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const productImage = await loadRecordProductImage(record.skuImage)
|
||||
const canvas = document.createElement('canvas')
|
||||
const scale = window.devicePixelRatio || 1
|
||||
const width = 720
|
||||
const height = 330
|
||||
canvas.width = width * scale
|
||||
canvas.height = height * scale
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('当前浏览器不支持图片生成')
|
||||
}
|
||||
|
||||
ctx.scale(scale, scale)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.fillStyle = '#1f2937'
|
||||
ctx.font = '20px sans-serif'
|
||||
ctx.fillText('订单详情', 28, 38)
|
||||
ctx.fillStyle = '#9ca3af'
|
||||
ctx.font = '24px sans-serif'
|
||||
ctx.fillText('×', width - 40, 38)
|
||||
|
||||
ctx.fillStyle = '#111827'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`购买时间:${formatAdminDateTime(record.createdAt)}`, width / 2, 88)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
drawTicketCard(ctx, record, productImage)
|
||||
drawRecordMeta(ctx, record)
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
function drawTicketCard(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
record: AdminCloudtentaclesDeliveryRecordItem,
|
||||
productImage: HTMLImageElement | null,
|
||||
) {
|
||||
const x = 170
|
||||
const y = 108
|
||||
const width = 392
|
||||
const height = 108
|
||||
const imageWidth = 106
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + 18, y)
|
||||
ctx.lineTo(x + width, y)
|
||||
ctx.lineTo(x + width, y + height - 22)
|
||||
ctx.lineTo(x + width - 18, y + height)
|
||||
ctx.lineTo(x, y + height)
|
||||
ctx.lineTo(x, y + 18)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = '#f8fbff'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = '#2f80d1'
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
const gradient = ctx.createLinearGradient(x, y, x + imageWidth, y + height)
|
||||
gradient.addColorStop(0, '#e7f1ff')
|
||||
gradient.addColorStop(1, '#b8d7ff')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, y, imageWidth, height)
|
||||
if (productImage) {
|
||||
drawCoverImage(ctx, productImage, x, y, imageWidth, height)
|
||||
} else {
|
||||
ctx.fillStyle = '#355d91'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('套装发货记录', x + imageWidth / 2, y + 25)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillRect(x + 34, y + 42, 38, 38)
|
||||
ctx.strokeStyle = '#8ab6ef'
|
||||
ctx.strokeRect(x + 34, y + 42, 38, 38)
|
||||
ctx.fillStyle = '#2f80d1'
|
||||
ctx.font = '20px sans-serif'
|
||||
ctx.fillText('GP', x + imageWidth / 2, y + 69)
|
||||
}
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
const contentX = x + imageWidth + 16
|
||||
const orderText = `订单号:${record.platformOrderId || record.recordId.slice(-8) || '-'}`
|
||||
ctx.fillStyle = '#1d5ea8'
|
||||
ctx.font = '16px sans-serif'
|
||||
drawEllipsisText(ctx, record.name || '未命名商品', contentX, y + 31, 170)
|
||||
ctx.fillStyle = '#3478c6'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(orderText, x + width - 12, y + 30)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
ctx.strokeStyle = '#c8d8ec'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(contentX, y + 42)
|
||||
ctx.lineTo(x + width - 12, y + 42)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#f59e0b'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.fillText(`购买机会 × ${record.count || 1}`, contentX, y + 66)
|
||||
ctx.fillStyle = '#16a34a'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.fillText(getStatusLabel(record.status), x + width - 12, y + 66)
|
||||
ctx.textAlign = 'left'
|
||||
|
||||
ctx.fillStyle = '#3478c6'
|
||||
ctx.font = '14px sans-serif'
|
||||
drawEllipsisText(ctx, `账号:${record.fulfillUser || record.phone || '-'}`, contentX, y + 90, 250)
|
||||
}
|
||||
|
||||
function drawRecordMeta(ctx: CanvasRenderingContext2D, record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const left = 80
|
||||
const top = 250
|
||||
const lineHeight = 22
|
||||
const values = [
|
||||
`订单店铺:${record.orderShopName || record.orderShopId || '-'}`,
|
||||
`核销店铺:${record.consumeShopName || record.consumeShopId || '-'}`,
|
||||
`绑定角色:${record.roleName || record.fulfillUser || '-'}${record.roleId ? `(${record.roleId})` : ''}`,
|
||||
]
|
||||
|
||||
ctx.fillStyle = '#374151'
|
||||
ctx.font = '13px sans-serif'
|
||||
values.forEach((value, index) => {
|
||||
drawEllipsisText(ctx, value, left, top + index * lineHeight, 560)
|
||||
})
|
||||
}
|
||||
|
||||
async function loadRecordProductImage(imageUrl: string) {
|
||||
const normalizedUrl = String(imageUrl || '').trim()
|
||||
if (!normalizedUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const proxyUrl = `/api/v1/admin/cloudtentacles-records/image?url=${encodeURIComponent(
|
||||
normalizedUrl,
|
||||
)}`
|
||||
return loadProtectedImage(proxyUrl).catch(() => null)
|
||||
}
|
||||
|
||||
async function loadProtectedImage(src: string) {
|
||||
const token = getAdminToken()
|
||||
const response = await fetch(src, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('商品图片加载失败')
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(await response.blob())
|
||||
try {
|
||||
return await loadImage(objectUrl)
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(src: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => reject(new Error('商品图片加载失败'))
|
||||
image.src = src
|
||||
})
|
||||
}
|
||||
|
||||
function drawCoverImage(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const ratio = Math.max(width / image.naturalWidth, height / image.naturalHeight)
|
||||
const drawWidth = image.naturalWidth * ratio
|
||||
const drawHeight = image.naturalHeight * ratio
|
||||
const drawX = x + (width - drawWidth) / 2
|
||||
const drawY = y + (height - drawHeight) / 2
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.rect(x, y, width, height)
|
||||
ctx.clip()
|
||||
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawEllipsisText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
) {
|
||||
let output = text
|
||||
while (output.length > 0 && ctx.measureText(output).width > maxWidth) {
|
||||
output = `${output.slice(0, -2)}…`
|
||||
}
|
||||
ctx.fillText(output || '-', x, y)
|
||||
}
|
||||
|
||||
function buildRecordImageFileName(record: AdminCloudtentaclesDeliveryRecordItem) {
|
||||
const name = sanitizeFileName(record.name || '发货记录')
|
||||
const id = sanitizeFileName(
|
||||
record.platformOrderId || record.recordId.slice(0, 8) || String(record.virtualNumberId || 'record'),
|
||||
)
|
||||
return `${name}-${id}.png`
|
||||
}
|
||||
|
||||
function sanitizeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 48)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSources()
|
||||
if (sourceKey.value) {
|
||||
await loadRecords(1)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cloud-records-page list-page">
|
||||
<AdminPageHeader title="查询发货记录" description="按订单号、cloudtentacles 账号和时间范围查询平台发货记录。">
|
||||
<template #extra>
|
||||
<span class="total-badge">共 {{ total }} 条记录</span>
|
||||
</template>
|
||||
</AdminPageHeader>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">查询条件</span>
|
||||
<span class="card-desc">输入订单号时会先关联本地任务,再匹配 cloudtentacles 发货记录。</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="filter-grid cloud-records-filter">
|
||||
<el-select
|
||||
v-model="sourceKey"
|
||||
class="filter-control"
|
||||
placeholder="cloudtentacles 账号"
|
||||
:loading="sourceLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="source in sourceOptions"
|
||||
:key="source.key"
|
||||
:label="`${source.label || source.key}${source.hasToken ? '' : ' · 未登录'}`"
|
||||
:value="source.key"
|
||||
:disabled="!source.enabled || !source.hasToken"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model.trim="platformOrderId"
|
||||
class="filter-control"
|
||||
clearable
|
||||
placeholder="订单号"
|
||||
@keyup.enter="loadRecords(1)"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
class="filter-control cloud-records-date-range"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<div class="filter-buttons">
|
||||
<el-button round @click="resetFilters">重置</el-button>
|
||||
<el-button round type="primary" :loading="loading" @click="loadRecords(1)">
|
||||
查询
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<el-card
|
||||
shadow="never"
|
||||
class="section-card"
|
||||
v-loading="loading"
|
||||
element-loading-text="发货记录加载中"
|
||||
>
|
||||
<template v-if="items.length === 0 && !loading">
|
||||
<el-empty description="当前筛选条件下暂无发货记录" :image-size="60" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-toolbar">
|
||||
<strong>发货记录</strong>
|
||||
<span>账号 {{ currentSourceLabel }} · 第 {{ page }} 页,当前展示 {{ items.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" stripe size="small" class="data-table cloud-records-table">
|
||||
<el-table-column label="商品 / 角色" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title" :title="row.name">{{ row.name || '-' }}</span>
|
||||
<span class="cell-subline" :title="row.fulfillUser">
|
||||
账号:{{ row.fulfillUser || '-' }}
|
||||
</span>
|
||||
<span v-if="row.roleName || row.roleId" class="cell-subline" :title="`${row.roleName} ${row.roleId}`">
|
||||
角色:{{ row.roleName || '-' }}{{ row.roleId ? `(${row.roleId})` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单 / 店铺" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title" :title="row.platformOrderId">
|
||||
{{ row.platformOrderId || '-' }}
|
||||
</span>
|
||||
<span class="cell-subline" :title="row.orderShopName || row.orderShopId">
|
||||
订单店铺:{{ row.orderShopName || row.orderShopId || '-' }}
|
||||
</span>
|
||||
<span class="cell-subline" :title="row.consumeShopName || row.consumeShopId">
|
||||
核销店铺:{{ row.consumeShopName || row.consumeShopId || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="虚拟号" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title">{{ row.phone || '-' }}</span>
|
||||
<span class="cell-subline">VN {{ row.virtualNumberId || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<div class="status-stack">
|
||||
<el-tag :type="getStatusType(row.status)" effect="plain">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
<span v-if="row.localDispatchStatus" class="cell-subline">
|
||||
{{ getLocalDispatchStatusLabel(row.localDispatchStatus) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" min-width="120">
|
||||
<template #default="{ row }">{{ row.operatorName || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatAdminDateTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="记录 ID" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-subline" :title="row.recordId">{{ row.recordId || '-' }}</span>
|
||||
<span v-if="row.taskNo" class="cell-subline" :title="row.taskNo">
|
||||
任务:{{ row.taskNo }}
|
||||
<template v-if="getMatchConfidenceLabel(row.matchConfidence)">
|
||||
· 匹配{{ getMatchConfidenceLabel(row.matchConfidence) }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="136" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" plain @click="generateRecordImage(row)">
|
||||
生成图片
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="cloud-records-pagination">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
:current-page="page"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[100, 200, 500, 1000]"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="previewVisible" title="发货记录图片" width="760px" destroy-on-close>
|
||||
<div class="record-preview">
|
||||
<img v-if="previewImageUrl" :src="previewImageUrl" alt="发货记录图片预览" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="downloadPreviewImage">下载图片</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '@/styles/admin-list-pages.css';
|
||||
|
||||
.cloud-records-filter {
|
||||
grid-template-columns: minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr) auto;
|
||||
}
|
||||
|
||||
.cloud-records-date-range {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cloud-records-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.record-preview {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 260px;
|
||||
padding: var(--space-3);
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.record-preview img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--border-default);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.cloud-records-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cloud-records-pagination {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,91 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||
import { fetchAdminDashboardSummary } from '@/services/admin'
|
||||
import type { AdminDashboardSummary } from '@/types/admin'
|
||||
|
||||
const loading = ref(true)
|
||||
const summary = ref<AdminDashboardSummary | null>(null)
|
||||
const errorMessage = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetchAdminDashboardSummary()
|
||||
summary.value = response.data
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取概览失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const cards = [
|
||||
{ key: 'todayOrders', label: '今日订单' },
|
||||
{ key: 'paidPendingClaim', label: '待领取' },
|
||||
{ key: 'claimingTasks', label: '领取中' },
|
||||
{ key: 'redeemedToday', label: '今日成功' },
|
||||
{ key: 'abnormalTasks', label: '异常任务' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<AdminPageHeader title="系统概览" description="快速查看订单和交付链路的运行状态。" />
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="12" animated class="mt-4" />
|
||||
|
||||
<div v-else class="summary-grid">
|
||||
<el-card v-for="card in cards" :key="card.key" shadow="never" class="stat-card">
|
||||
<span class="stat-label">{{ card.label }}</span>
|
||||
<strong class="stat-value">{{ summary?.[card.key] ?? 0 }}</strong>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
font-size: var(--text-3xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import AdminKuaishouCloudFulfillmentView from './kuaishou-cloud/AdminKuaishouCloudFulfillmentView.vue'
|
||||
import AdminKuaishouFeifeiFulfillmentView from './kuaishou-feifei/AdminKuaishouFeifeiFulfillmentView.vue'
|
||||
|
||||
const activeFulfillmentTab = ref('kuaishou-feifei')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hub-panel">
|
||||
<AdminPageHeader
|
||||
title="履约配置中心"
|
||||
description="维护 91 卡券快手商品到 Cloud 与 kuaishou-feifei 的履约映射规则。"
|
||||
/>
|
||||
|
||||
<el-tabs v-model="activeFulfillmentTab" class="fulfillment-tabs">
|
||||
<el-tab-pane label="kuaishou-feifei" name="kuaishou-feifei">
|
||||
<AdminKuaishouFeifeiFulfillmentView />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="kuaishou-cloud" name="kuaishou-cloud">
|
||||
<AdminKuaishouCloudFulfillmentView />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hub-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.fulfillment-tabs {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
-271
@@ -1,271 +0,0 @@
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.fulfillment-simple-page {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fulfillment-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(120px, auto);
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.metric-card span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.overview-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fulfillment-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.source-panel,
|
||||
.catalog-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.source-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.catalog-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.section-head h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.source-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.source-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.source-item:hover,
|
||||
.source-item--active {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.source-item span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-item strong,
|
||||
.source-item small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-item strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.source-item small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.catalog-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.catalog-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.match-checker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.sku-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sku-list-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.sku-name-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sku-name-cell strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sku-name-cell small {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.match-name {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-primary);
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.override-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.override-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.override-rule-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.override-rule-item {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.override-rule-head {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(220px, 1fr) minmax(160px, 220px) auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.delivery-item-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.delivery-item-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.1fr) minmax(180px, 1fr) 120px auto;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.fulfillment-overview,
|
||||
.fulfillment-layout,
|
||||
.match-checker,
|
||||
.override-rule-head,
|
||||
.delivery-item-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-actions,
|
||||
.catalog-toolbar,
|
||||
.catalog-toolbar-actions,
|
||||
.override-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.catalog-toolbar {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
-545
@@ -1,545 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminCloudtentaclesOverrideRules,
|
||||
fetchAdminCloudtentaclesSkuList,
|
||||
fetchAdminCloudtentaclesSourceConfig,
|
||||
saveAdminCloudtentaclesOverrideRules,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminCloudtentaclesOverrideDeliveryItem,
|
||||
AdminCloudtentaclesOverrideRule,
|
||||
AdminCloudtentaclesSessionItem,
|
||||
AdminCloudtentaclesSkuItem,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
AdminCloudtentaclesSessionsMap,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
type MatchState = {
|
||||
input: string
|
||||
}
|
||||
|
||||
type EditableOverrideRule = Omit<AdminCloudtentaclesOverrideRule, 'normalizedProductName'> & {
|
||||
normalizedProductName?: string
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const skuLoading = ref(false)
|
||||
const overrideLoading = ref(false)
|
||||
const overrideSaving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const skuErrorMessage = ref('')
|
||||
const overrideErrorMessage = ref('')
|
||||
const selectedSourceKey = ref('')
|
||||
const sources = ref<AdminCloudtentaclesSourceItem[]>([])
|
||||
const sessions = ref<AdminCloudtentaclesSessionsMap>({})
|
||||
const skuItems = ref<AdminCloudtentaclesSkuItem[]>([])
|
||||
const overrideEnabled = ref(true)
|
||||
const overrideRules = ref<EditableOverrideRule[]>([])
|
||||
const matchState = reactive<MatchState>({
|
||||
input: '',
|
||||
})
|
||||
|
||||
const sourceRows = computed(() =>
|
||||
sources.value.map((source) => {
|
||||
const session = resolveSourceSession(source.key)
|
||||
return {
|
||||
...source,
|
||||
session,
|
||||
ready: source.enabled !== false && Boolean(session?.hasToken),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const readySources = computed(() => sourceRows.value.filter((source) => source.ready))
|
||||
const selectedSource = computed(
|
||||
() => sourceRows.value.find((source) => source.key === selectedSourceKey.value) || null,
|
||||
)
|
||||
|
||||
const metrics = computed(() => ({
|
||||
sourceCount: sources.value.length,
|
||||
readySourceCount: readySources.value.length,
|
||||
skuCount: skuItems.value.length,
|
||||
inventoryTotal: skuItems.value.reduce((sum, item) => sum + Math.max(0, Number(item.inventory || 0)), 0),
|
||||
}))
|
||||
|
||||
const matchedSku = computed(() => {
|
||||
const normalizedInput = normalizeMatchName(matchState.input)
|
||||
if (!normalizedInput) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
skuItems.value.find((item) => String(item.name || '').trim() === matchState.input.trim()) ||
|
||||
skuItems.value.find((item) => normalizeMatchName(item.name) === normalizedInput) ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const overrideRuleCount = computed(() => overrideRules.value.length)
|
||||
|
||||
watch(selectedSourceKey, (sourceKey) => {
|
||||
if (sourceKey) {
|
||||
void loadSkuList(sourceKey)
|
||||
} else {
|
||||
skuItems.value = []
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadPage)
|
||||
|
||||
async function loadPage() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const [sourceResponse, overrideResponse] = await Promise.all([
|
||||
fetchAdminCloudtentaclesSourceConfig(),
|
||||
fetchAdminCloudtentaclesOverrideRules(),
|
||||
])
|
||||
sources.value = Array.isArray(sourceResponse.data.sources) ? sourceResponse.data.sources : []
|
||||
sessions.value = sourceResponse.data.sessions || {}
|
||||
overrideEnabled.value = overrideResponse.data.enabled !== false
|
||||
overrideRules.value = Array.isArray(overrideResponse.data.rules)
|
||||
? overrideResponse.data.rules.map((rule) => normalizeEditableRule(rule))
|
||||
: []
|
||||
selectedSourceKey.value = resolveDefaultSourceKey()
|
||||
|
||||
if (!selectedSourceKey.value) {
|
||||
skuItems.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 cloudtentacles 账号失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOverrideRules() {
|
||||
overrideSaving.value = true
|
||||
overrideErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await saveAdminCloudtentaclesOverrideRules({
|
||||
enabled: overrideEnabled.value,
|
||||
rules: overrideRules.value.map((rule) => ({
|
||||
id: rule.id,
|
||||
enabled: rule.enabled,
|
||||
productName: rule.productName,
|
||||
sourceKey: rule.sourceKey,
|
||||
deliveryItems: rule.deliveryItems,
|
||||
notes: rule.notes,
|
||||
})),
|
||||
})
|
||||
overrideEnabled.value = response.data.enabled !== false
|
||||
overrideRules.value = Array.isArray(response.data.rules)
|
||||
? response.data.rules.map((rule) => normalizeEditableRule(rule))
|
||||
: []
|
||||
showSuccess(`商品覆盖规则已保存,共 ${overrideRules.value.length} 条`)
|
||||
} catch (error) {
|
||||
overrideErrorMessage.value = error instanceof Error ? error.message : '保存 cloudtentacles 覆盖规则失败'
|
||||
showError(overrideErrorMessage.value)
|
||||
} finally {
|
||||
overrideSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkuList(sourceKey = selectedSourceKey.value) {
|
||||
if (!sourceKey) {
|
||||
skuItems.value = []
|
||||
return
|
||||
}
|
||||
|
||||
skuLoading.value = true
|
||||
skuErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminCloudtentaclesSkuList({ sourceKey })
|
||||
skuItems.value = Array.isArray(response.data.items) ? response.data.items : []
|
||||
} catch (error) {
|
||||
skuItems.value = []
|
||||
skuErrorMessage.value = error instanceof Error ? error.message : '读取 cloudtentacles 商品失败'
|
||||
} finally {
|
||||
skuLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
void loadPage()
|
||||
}
|
||||
|
||||
function addOverrideRule() {
|
||||
overrideRules.value.unshift({
|
||||
id: createRuleId(),
|
||||
enabled: true,
|
||||
productName: matchState.input.trim(),
|
||||
normalizedProductName: normalizeMatchName(matchState.input),
|
||||
sourceKey: selectedSourceKey.value,
|
||||
deliveryItems: [createDeliveryItem()],
|
||||
notes: '',
|
||||
})
|
||||
}
|
||||
|
||||
function removeOverrideRule(index: number) {
|
||||
overrideRules.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function addDeliveryItem(rule: EditableOverrideRule) {
|
||||
rule.deliveryItems.push(createDeliveryItem())
|
||||
}
|
||||
|
||||
function removeDeliveryItem(rule: EditableOverrideRule, index: number) {
|
||||
rule.deliveryItems.splice(index, 1)
|
||||
if (rule.deliveryItems.length === 0) {
|
||||
rule.deliveryItems.push(createDeliveryItem())
|
||||
}
|
||||
}
|
||||
|
||||
function handleRuleSourceChange(sourceKey: string) {
|
||||
if (sourceKey) {
|
||||
selectedSourceKey.value = sourceKey
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeliverySkuChange(item: AdminCloudtentaclesOverrideDeliveryItem, skuId: number) {
|
||||
const sku = skuItems.value.find((current) => Number(current.id || 0) === Number(skuId || 0))
|
||||
item.cloudSkuId = Number(skuId || 0) || 0
|
||||
item.cloudSkuName = String(sku?.name || item.cloudSkuName || '').trim()
|
||||
}
|
||||
|
||||
function resolveDefaultSourceKey() {
|
||||
const current = sourceRows.value.find((source) => source.key === selectedSourceKey.value)
|
||||
if (current?.ready) {
|
||||
return current.key
|
||||
}
|
||||
|
||||
return readySources.value[0]?.key || sourceRows.value[0]?.key || ''
|
||||
}
|
||||
|
||||
function resolveSourceSession(sourceKey: string): AdminCloudtentaclesSessionItem | null {
|
||||
return sessions.value[sourceKey] || null
|
||||
}
|
||||
|
||||
function normalizeMatchName(value: unknown) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[【】\[\]()()]/g, ' ')
|
||||
.replace(/(自动发货|秒发|极速发货|官方直充|官方充值)/gi, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function formatSourceLabel(source: AdminCloudtentaclesSourceItem) {
|
||||
return source.label || source.username || source.key
|
||||
}
|
||||
|
||||
function formatCloudPrice(value: unknown) {
|
||||
const price = Number(value)
|
||||
return Number.isFinite(price) ? String(price) : '-'
|
||||
}
|
||||
|
||||
function formatSkuOption(sku: AdminCloudtentaclesSkuItem) {
|
||||
return `#${sku.id} ${sku.name}`
|
||||
}
|
||||
|
||||
function getSourceTagType(source: { ready: boolean; enabled?: boolean }) {
|
||||
if (source.ready) return 'success'
|
||||
if (source.enabled === false) return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function getSourceStatusText(source: { ready: boolean; enabled?: boolean; session: AdminCloudtentaclesSessionItem | null }) {
|
||||
if (source.ready) return '可用'
|
||||
if (source.enabled === false) return '已停用'
|
||||
if (!source.session?.hasToken) return '未登录'
|
||||
return '待检查'
|
||||
}
|
||||
|
||||
function normalizeEditableRule(rule: AdminCloudtentaclesOverrideRule): EditableOverrideRule {
|
||||
return {
|
||||
id: String(rule.id || createRuleId()).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
productName: String(rule.productName || '').trim(),
|
||||
normalizedProductName: String(rule.normalizedProductName || '').trim(),
|
||||
sourceKey: String(rule.sourceKey || '').trim(),
|
||||
deliveryItems: Array.isArray(rule.deliveryItems) && rule.deliveryItems.length > 0
|
||||
? rule.deliveryItems.map((item) => ({
|
||||
cloudSkuId: Number(item.cloudSkuId || 0) || 0,
|
||||
cloudSkuName: String(item.cloudSkuName || '').trim(),
|
||||
quantity: Math.max(1, Number(item.quantity || 1) || 1),
|
||||
}))
|
||||
: [createDeliveryItem()],
|
||||
notes: String(rule.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function createDeliveryItem(): AdminCloudtentaclesOverrideDeliveryItem {
|
||||
const firstSku = skuItems.value[0]
|
||||
return {
|
||||
cloudSkuId: Number(firstSku?.id || 0) || 0,
|
||||
cloudSkuName: String(firstSku?.name || '').trim(),
|
||||
quantity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function createRuleId() {
|
||||
return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel fulfillment-simple-page">
|
||||
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以查看履约配置" />
|
||||
|
||||
<template v-else>
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="10" animated />
|
||||
|
||||
<template v-else>
|
||||
<section class="fulfillment-overview">
|
||||
<div class="metric-card">
|
||||
<span>履约账号</span>
|
||||
<strong>{{ metrics.readySourceCount }} / {{ metrics.sourceCount }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>可发商品</span>
|
||||
<strong>{{ metrics.skuCount }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>总库存</span>
|
||||
<strong>{{ metrics.inventoryTotal }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>覆盖规则</span>
|
||||
<strong>{{ overrideRuleCount }}</strong>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<el-button :loading="loading || skuLoading" @click="refreshAll">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fulfillment-layout">
|
||||
<aside class="source-panel">
|
||||
<div class="section-head">
|
||||
<h3>cloudtentacles 账号</h3>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="sourceRows.length === 0" description="暂无账号,请先到平台店铺配置登录。" />
|
||||
|
||||
<div v-else class="source-list">
|
||||
<button
|
||||
v-for="source in sourceRows"
|
||||
:key="source.key"
|
||||
type="button"
|
||||
class="source-item"
|
||||
:class="{ 'source-item--active': source.key === selectedSourceKey }"
|
||||
@click="selectedSourceKey = source.key"
|
||||
>
|
||||
<span>
|
||||
<strong>{{ formatSourceLabel(source) }}</strong>
|
||||
<small>{{ source.username || source.key }}</small>
|
||||
</span>
|
||||
<el-tag :type="getSourceTagType(source)" effect="light">
|
||||
{{ getSourceStatusText(source) }}
|
||||
</el-tag>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="catalog-panel">
|
||||
<div class="catalog-toolbar">
|
||||
<div class="section-head">
|
||||
<h3>匹配状态与覆盖规则</h3>
|
||||
<p v-if="selectedSource">
|
||||
当前账号:{{ formatSourceLabel(selectedSource) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="catalog-toolbar-actions">
|
||||
<el-button :loading="skuLoading" :disabled="!selectedSourceKey" @click="loadSkuList()">
|
||||
刷新商品
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="skuErrorMessage"
|
||||
:title="skuErrorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="match-checker">
|
||||
<el-input
|
||||
v-model="matchState.input"
|
||||
clearable
|
||||
placeholder="输入 91 卡券 productNo / 商品名字,查看会命中哪个 cloudtentacles 商品"
|
||||
/>
|
||||
<el-tag v-if="matchedSku" type="success" effect="light">
|
||||
命中 #{{ matchedSku.id }} {{ matchedSku.name }}
|
||||
</el-tag>
|
||||
<el-tag v-else-if="matchState.input.trim()" type="warning" effect="light">
|
||||
未命中
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<section class="override-panel">
|
||||
<div class="catalog-toolbar">
|
||||
<div class="section-head">
|
||||
<h3>商品覆盖规则</h3>
|
||||
<p>套装、多数量商品会优先按这里发货;没有规则时继续同名匹配。</p>
|
||||
</div>
|
||||
<div class="override-actions">
|
||||
<el-switch v-model="overrideEnabled" active-text="启用" inactive-text="停用" />
|
||||
<el-button @click="addOverrideRule">新增规则</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="overrideLoading || overrideSaving"
|
||||
@click="saveOverrideRules"
|
||||
>
|
||||
保存规则
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="overrideErrorMessage"
|
||||
:title="overrideErrorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-empty v-if="overrideRules.length === 0" description="暂无覆盖规则" />
|
||||
|
||||
<div v-else class="override-rule-list">
|
||||
<article
|
||||
v-for="(rule, ruleIndex) in overrideRules"
|
||||
:key="rule.id"
|
||||
class="override-rule-item"
|
||||
>
|
||||
<div class="override-rule-head">
|
||||
<el-switch v-model="rule.enabled" active-text="启用" inactive-text="停用" />
|
||||
<el-input v-model="rule.productName" placeholder="91 商品名 / productNo" clearable />
|
||||
<el-select
|
||||
v-model="rule.sourceKey"
|
||||
clearable
|
||||
placeholder="全部账号"
|
||||
@change="handleRuleSourceChange"
|
||||
>
|
||||
<el-option label="全部账号" value="" />
|
||||
<el-option
|
||||
v-for="source in sourceRows"
|
||||
:key="source.key"
|
||||
:label="formatSourceLabel(source)"
|
||||
:value="source.key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="danger" plain @click="removeOverrideRule(ruleIndex)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="delivery-item-list">
|
||||
<div
|
||||
v-for="(item, itemIndex) in rule.deliveryItems"
|
||||
:key="`${rule.id}-${itemIndex}`"
|
||||
class="delivery-item-row"
|
||||
>
|
||||
<el-select
|
||||
v-model="item.cloudSkuId"
|
||||
filterable
|
||||
placeholder="选择 cloudtentacles 商品"
|
||||
@change="(value: number) => handleDeliverySkuChange(item, value)"
|
||||
>
|
||||
<el-option
|
||||
v-for="sku in skuItems"
|
||||
:key="sku.id"
|
||||
:label="formatSkuOption(sku)"
|
||||
:value="sku.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input v-model="item.cloudSkuName" placeholder="cloudtentacles 商品名" clearable />
|
||||
<el-input-number v-model="item.quantity" :min="1" :max="999" controls-position="right" />
|
||||
<el-button plain @click="removeDeliveryItem(rule, itemIndex)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button plain @click="addDeliveryItem(rule)">增加发货商品</el-button>
|
||||
</div>
|
||||
|
||||
<el-input v-model="rule.notes" placeholder="备注" clearable />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sku-list-panel">
|
||||
<div class="section-head">
|
||||
<h3>cloudtentacles 商品列表</h3>
|
||||
<p>用于核对商品名、库存和覆盖规则里的发货商品。</p>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="skuLoading"
|
||||
:data="skuItems"
|
||||
height="420"
|
||||
border
|
||||
stripe
|
||||
class="sku-table"
|
||||
empty-text="当前账号暂无可展示商品"
|
||||
>
|
||||
<el-table-column label="cloudtentacles 商品" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="sku-name-cell">
|
||||
<strong>{{ row.name || '-' }}</strong>
|
||||
<small>#{{ row.id }} · {{ row.description || row.name || '-' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="91 自动匹配名" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<code class="match-name">{{ row.name || '-' }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="120">
|
||||
<template #default="{ row }">{{ formatCloudPrice(row.price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" prop="inventory" width="120" sortable />
|
||||
<el-table-column label="发货限制" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.buyLimitMin || 1 }} - {{ row.buyLimitMax || 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="Number(row.inventory || 0) > 0 ? 'success' : 'danger'" effect="light">
|
||||
{{ Number(row.inventory || 0) > 0 ? '可履约' : '无库存' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./AdminKuaishouCloudFulfillment.css"></style>
|
||||
-349
@@ -1,349 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { RefreshRight, Search } from '@element-plus/icons-vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
fetchAdminKuaishouFeifeiConfig,
|
||||
matchAdminKuaishouFeifeiProduct,
|
||||
syncAdminKuaishouFeifeiProducts,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminKuaishouFeifeiConfigResponse,
|
||||
AdminKuaishouFeifeiMatchResult,
|
||||
AdminKuaishouFeifeiProductRule,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
const loading = ref(true)
|
||||
const syncing = ref(false)
|
||||
const matching = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const filePath = ref('')
|
||||
const matchInput = ref('')
|
||||
const matchResult = ref<AdminKuaishouFeifeiMatchResult | null>(null)
|
||||
const rules = ref<AdminKuaishouFeifeiProductRule[]>([])
|
||||
const lastSyncText = ref('')
|
||||
|
||||
const enabledRuleCount = computed(() => rules.value.filter((rule) => rule.enabled !== false).length)
|
||||
const mappingCount = computed(() =>
|
||||
rules.value.filter((rule) => rule.productName.trim() && rule.productCode.trim()).length,
|
||||
)
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
label: '名字映射',
|
||||
value: String(mappingCount.value),
|
||||
detail: `启用 ${enabledRuleCount.value} 条`,
|
||||
},
|
||||
{
|
||||
label: '匹配方式',
|
||||
value: '同名',
|
||||
detail: '使用 91 商品名规范化匹配',
|
||||
},
|
||||
{
|
||||
label: '配置文件',
|
||||
value: filePath.value ? 'GUI' : '默认',
|
||||
detail: filePath.value || 'data/kuaishou-feifei-config.json',
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(loadConfig)
|
||||
|
||||
async function loadConfig() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminKuaishouFeifeiConfig()
|
||||
hydrateConfig(response.data)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 kuaishou-feifei 履约映射失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncProducts() {
|
||||
syncing.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await syncAdminKuaishouFeifeiProducts({
|
||||
status: 'on_sale',
|
||||
})
|
||||
hydrateConfig(response.data)
|
||||
lastSyncText.value = `商品 ${response.data.sync.productCount} 个,映射 ${response.data.sync.ruleCount} 条`
|
||||
showSuccess(`kuaishou-feifei 商品映射已同步,${lastSyncText.value}`)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '同步 kuaishou-feifei 商品映射失败'
|
||||
showError(errorMessage.value)
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function previewMatch() {
|
||||
const productName = matchInput.value.trim()
|
||||
if (!productName) {
|
||||
showError('请输入 91 商品名')
|
||||
return
|
||||
}
|
||||
|
||||
matching.value = true
|
||||
matchResult.value = null
|
||||
|
||||
try {
|
||||
const response = await matchAdminKuaishouFeifeiProduct(productName)
|
||||
matchResult.value = response.data
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '预览 kuaishou-feifei 映射失败')
|
||||
} finally {
|
||||
matching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateConfig(data: AdminKuaishouFeifeiConfigResponse) {
|
||||
filePath.value = data.filePath || ''
|
||||
rules.value = Array.isArray(data.source.productRules)
|
||||
? data.source.productRules.map((rule) => normalizeRule(rule))
|
||||
: []
|
||||
}
|
||||
|
||||
function normalizeRule(rule: Partial<AdminKuaishouFeifeiProductRule>): AdminKuaishouFeifeiProductRule {
|
||||
const productName = String(rule.productName || '').trim()
|
||||
const productCode = String(rule.productCode || '').trim()
|
||||
|
||||
return {
|
||||
id: String(rule.id || productName || productCode).trim(),
|
||||
enabled: rule.enabled !== false,
|
||||
productName,
|
||||
normalizedProductName: String(rule.normalizedProductName || '').trim(),
|
||||
productCode,
|
||||
skuName: String(rule.skuName || productName || productCode).trim(),
|
||||
notes: String(rule.notes || '').trim(),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="feifei-fulfillment-page">
|
||||
<el-result v-if="!hasAdminRole('admin')" icon="warning" title="仅管理员可以查看履约配置" />
|
||||
|
||||
<template v-else>
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
:title="errorMessage"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<section class="rule-overview">
|
||||
<div v-for="metric in metrics" :key="metric.label" class="metric-card">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
<small>{{ metric.detail }}</small>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<el-button :icon="RefreshRight" :loading="loading" @click="loadConfig">刷新</el-button>
|
||||
<el-button type="primary" :icon="RefreshRight" :loading="syncing" @click="syncProducts">
|
||||
同步商品映射
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rule-panel">
|
||||
<div class="section-head rule-head-title">
|
||||
<div>
|
||||
<h3>商品名字映射</h3>
|
||||
<p>{{ lastSyncText || 'feifei 商品名会按规范化名字匹配 91 商品名。' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="match-checker">
|
||||
<el-input
|
||||
v-model="matchInput"
|
||||
clearable
|
||||
placeholder="91 商品名,例如 套装-Alan Walker"
|
||||
@keyup.enter="previewMatch"
|
||||
/>
|
||||
<el-button :icon="Search" :loading="matching" @click="previewMatch">预览命中</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="matchResult" class="match-result" :class="{ 'is-matched': matchResult.matched }">
|
||||
<strong>{{ matchResult.matched ? '已命中' : '未命中' }}</strong>
|
||||
<span v-if="matchResult.match">
|
||||
{{ matchResult.match.productName }} -> {{ matchResult.match.productCode }}
|
||||
</span>
|
||||
<span v-else>{{ matchResult.normalizedProductName || matchResult.productName }}</span>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="rules.length === 0" description="暂无 kuaishou-feifei 商品映射" />
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
:data="rules"
|
||||
border
|
||||
stripe
|
||||
height="520"
|
||||
class="rule-table"
|
||||
>
|
||||
<el-table-column label="feifei 商品名" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<strong>{{ row.productName || row.skuName || '-' }}</strong>
|
||||
<small>{{ row.normalizedProductName || '-' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="product_code" prop="productCode" min-width="150" />
|
||||
<el-table-column label="展示名称" prop="skuName" min-width="220" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled !== false ? 'success' : 'info'" effect="light">
|
||||
{{ row.enabled !== false ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" prop="notes" min-width="180" />
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feifei-fulfillment-page {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rule-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(190px, auto);
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.rule-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.metric-card small,
|
||||
.section-head p,
|
||||
.product-cell small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.overview-actions,
|
||||
.rule-head-title,
|
||||
.match-checker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.overview-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.rule-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.section-head h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.rule-head-title {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.match-checker {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.match-result {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.match-result.is-matched {
|
||||
color: var(--el-color-success);
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
.rule-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-cell strong,
|
||||
.product-cell small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rule-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-actions,
|
||||
.rule-head-title,
|
||||
.match-checker {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,450 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Bell,
|
||||
Connection,
|
||||
Monitor,
|
||||
DataAnalysis,
|
||||
Document,
|
||||
Expand,
|
||||
Fold,
|
||||
List,
|
||||
Memo,
|
||||
Setting,
|
||||
SwitchButton,
|
||||
Tickets,
|
||||
User,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
import { showSuccess } from '@/lib/feedback'
|
||||
import { logoutAdmin } from '@/services/admin'
|
||||
import {
|
||||
clearAdminSession,
|
||||
getAdminRole,
|
||||
getAdminTokenExpiresAt,
|
||||
getAdminUsername,
|
||||
} from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const sidebarStorageKey = 'admin-sidebar-collapsed'
|
||||
const sidebarMenuOpenedsStorageKey = 'admin-sidebar-menu-openeds'
|
||||
const isSidebarCollapsed = ref(
|
||||
typeof window !== 'undefined' && window.localStorage.getItem(sidebarStorageKey) === '1',
|
||||
)
|
||||
const sidebarMenuOpeneds = ref(readSidebarMenuOpeneds())
|
||||
|
||||
const currentRole = computed(() => getAdminRole())
|
||||
const isAdmin = computed(() => currentRole.value === 'admin')
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
if (currentRole.value === 'admin') return '管理员'
|
||||
if (currentRole.value === 'support') return '客服'
|
||||
return '普通运营'
|
||||
})
|
||||
|
||||
type AdminNavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof DataAnalysis
|
||||
children?: AdminNavItem[]
|
||||
}
|
||||
|
||||
const platformConfigNavItems = computed<AdminNavItem[]>(() => {
|
||||
return [
|
||||
{ to: '/admin/platform-shops?tab=ninetyone', label: '91卡券', icon: Connection },
|
||||
{ to: '/admin/platform-shops?tab=notifications', label: '内部通知', icon: Bell },
|
||||
{ to: '/admin/platform-shops?tab=kuaishouEticket', label: '快手小店核销', icon: Tickets },
|
||||
{ to: '/admin/platform-shops?tab=kuaishouFeifei', label: 'kuaishou-feifei', icon: Tickets },
|
||||
{
|
||||
to: '/admin/platform-shops?tab=cloudtentacles',
|
||||
label: 'cloudtentacles',
|
||||
icon: Setting,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items: AdminNavItem[] = [
|
||||
{ to: '/admin/dashboard', label: '概览', icon: DataAnalysis },
|
||||
]
|
||||
|
||||
if (isAdmin.value) {
|
||||
items.push({ to: '/admin/users', label: '用户', icon: User })
|
||||
}
|
||||
|
||||
items.push(
|
||||
{ to: '/admin/orders', label: '订单', icon: Document },
|
||||
{ to: '/admin/tasks', label: '任务', icon: List },
|
||||
{ to: '/admin/cloudtentacles-records', label: '查询发货记录', icon: Memo },
|
||||
)
|
||||
|
||||
if (isAdmin.value) {
|
||||
items.push(
|
||||
{
|
||||
to: '/admin/platform-shops',
|
||||
label: '平台配置',
|
||||
icon: Setting,
|
||||
children: platformConfigNavItems.value,
|
||||
},
|
||||
{ to: '/admin/platform-fulfillment', label: '履约配置', icon: Tickets },
|
||||
{ to: '/admin/audit-logs', label: '审计', icon: Monitor },
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const activeNavIndex = computed(() => {
|
||||
if (route.path === '/admin/platform-shops') {
|
||||
const tab = typeof route.query.tab === 'string' ? route.query.tab : 'ninetyone'
|
||||
return `/admin/platform-shops?tab=${tab}`
|
||||
}
|
||||
|
||||
return route.path
|
||||
})
|
||||
|
||||
function toggleSidebarCollapse() {
|
||||
isSidebarCollapsed.value = !isSidebarCollapsed.value
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(sidebarStorageKey, isSidebarCollapsed.value ? '1' : '0')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSidebarMenuOpen(index: string) {
|
||||
const normalizedIndex = String(index || '').trim()
|
||||
if (!normalizedIndex) return
|
||||
|
||||
sidebarMenuOpeneds.value = Array.from(new Set([...sidebarMenuOpeneds.value, normalizedIndex]))
|
||||
writeSidebarMenuOpeneds(sidebarMenuOpeneds.value)
|
||||
}
|
||||
|
||||
function handleSidebarMenuClose(index: string) {
|
||||
const normalizedIndex = String(index || '').trim()
|
||||
sidebarMenuOpeneds.value = sidebarMenuOpeneds.value.filter((item) => item !== normalizedIndex)
|
||||
writeSidebarMenuOpeneds(sidebarMenuOpeneds.value)
|
||||
}
|
||||
|
||||
async function submitLogout() {
|
||||
try {
|
||||
await logoutAdmin()
|
||||
} catch {
|
||||
// 后台退出是无状态的,本地清理优先
|
||||
} finally {
|
||||
clearAdminSession()
|
||||
showSuccess('已退出后台')
|
||||
await router.replace('/admin/login')
|
||||
}
|
||||
}
|
||||
|
||||
function readSidebarMenuOpeneds(): string[] {
|
||||
if (typeof window === 'undefined') {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(sidebarMenuOpenedsStorageKey)
|
||||
if (!rawValue) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawValue)
|
||||
return Array.isArray(parsed)
|
||||
? Array.from(new Set(parsed.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeSidebarMenuOpeneds(openeds: string[]) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.localStorage.setItem(sidebarMenuOpenedsStorageKey, JSON.stringify(openeds))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page" :class="{ 'is-sidebar-collapsed': isSidebarCollapsed }">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div v-if="!isSidebarCollapsed" class="brand-copy">
|
||||
<strong>运营后台</strong>
|
||||
<p>订单与交付管理</p>
|
||||
</div>
|
||||
<el-tooltip :content="isSidebarCollapsed ? '展开导航' : '折叠导航'" placement="right">
|
||||
<el-button
|
||||
class="sidebar-toggle"
|
||||
circle
|
||||
:aria-label="isSidebarCollapsed ? '展开导航' : '折叠导航'"
|
||||
@click="toggleSidebarCollapse"
|
||||
>
|
||||
<el-icon><component :is="isSidebarCollapsed ? Expand : Fold" /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-if="!isSidebarCollapsed" class="sidebar-user">
|
||||
<div class="user-name">{{ getAdminUsername() || '未读取' }}</div>
|
||||
<div class="user-role">{{ roleLabel }}</div>
|
||||
<div class="user-expiry">有效期至 {{ formatAdminDateTime(getAdminTokenExpiresAt()) }}</div>
|
||||
</div>
|
||||
|
||||
<el-menu
|
||||
:default-active="activeNavIndex"
|
||||
:default-openeds="sidebarMenuOpeneds"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
:collapse="isSidebarCollapsed"
|
||||
:collapse-transition="false"
|
||||
:ellipsis="false"
|
||||
@open="handleSidebarMenuOpen"
|
||||
@close="handleSidebarMenuClose"
|
||||
>
|
||||
<template v-for="item in navItems" :key="item.to">
|
||||
<el-sub-menu v-if="item.children?.length" :index="item.to">
|
||||
<template #title>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</template>
|
||||
|
||||
<el-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.to"
|
||||
:index="child.to"
|
||||
:title="child.label"
|
||||
>
|
||||
<el-icon><component :is="child.icon" /></el-icon>
|
||||
<span>{{ child.label }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item v-else :index="item.to" :title="item.label">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
|
||||
<el-tooltip :disabled="!isSidebarCollapsed" content="退出登录" placement="right">
|
||||
<el-button
|
||||
class="logout-btn"
|
||||
:circle="isSidebarCollapsed"
|
||||
:round="!isSidebarCollapsed"
|
||||
@click="submitLogout"
|
||||
>
|
||||
<el-icon v-if="isSidebarCollapsed"><SwitchButton /></el-icon>
|
||||
<span v-else>退出登录</span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</aside>
|
||||
|
||||
<section class="admin-content">
|
||||
<RouterView />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-page {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 232px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
transition: grid-template-columns 0.18s ease;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(73, 136, 255, 0.1), transparent 30%),
|
||||
linear-gradient(180deg, var(--bg-page) 0%, #edf3fb 100%);
|
||||
}
|
||||
|
||||
.admin-page.is-sidebar-collapsed {
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-6) 0;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--border-default);
|
||||
background: var(--bg-sidebar);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-5);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
display: block;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.brand-copy p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.sidebar-user {
|
||||
margin: 0 var(--space-4) var(--space-4);
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: #f5f8fc;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.user-expiry {
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item),
|
||||
.sidebar-menu :deep(.el-sub-menu__title) {
|
||||
margin: 2px var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
min-height: 44px;
|
||||
line-height: 44px;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item.is-active),
|
||||
.sidebar-menu :deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-sub-menu .el-menu-item) {
|
||||
margin-left: var(--space-4);
|
||||
min-height: 38px;
|
||||
line-height: 38px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-sub-menu .el-menu-item.is-active) {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
margin: var(--space-3) var(--space-4) 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.is-sidebar-collapsed .admin-sidebar {
|
||||
align-items: stretch;
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
.is-sidebar-collapsed .sidebar-brand {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item),
|
||||
.is-sidebar-collapsed .sidebar-menu :deep(.el-sub-menu__title) {
|
||||
justify-content: center;
|
||||
margin: 4px 10px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item .el-icon),
|
||||
.is-sidebar-collapsed .sidebar-menu :deep(.el-sub-menu__title .el-icon) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.is-sidebar-collapsed .logout-btn {
|
||||
width: 40px;
|
||||
margin: var(--space-3) auto 0;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-page {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
grid-template-columns: 1fr;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.admin-page.is-sidebar-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.is-sidebar-collapsed .admin-sidebar {
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.is-sidebar-collapsed .sidebar-menu :deep(.el-menu-item),
|
||||
.is-sidebar-collapsed .sidebar-menu :deep(.el-sub-menu__title) {
|
||||
justify-content: flex-start;
|
||||
margin: 2px var(--space-2);
|
||||
padding: 0 var(--space-5);
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,412 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import { fetchAdminOrderDetail } from '@/services/admin'
|
||||
import type { AdminOrderDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<AdminOrderDetail | null>(null)
|
||||
|
||||
type TagType = 'success' | 'primary' | 'warning' | 'danger' | 'info'
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetchAdminOrderDetail(String(route.params.orderId || ''))
|
||||
detail.value = response.data
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取订单详情失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatJsonBlock(value: Record<string, unknown>) {
|
||||
return JSON.stringify(value || {}, null, 2)
|
||||
}
|
||||
|
||||
function formatSpecSummary(spec: Record<string, unknown>) {
|
||||
const entries = Object.entries(spec || {})
|
||||
.filter(([, value]) => value !== null && value !== undefined && String(value).trim() !== '')
|
||||
.slice(0, 4)
|
||||
.map(([key, value]) => `${key}: ${String(value)}`)
|
||||
|
||||
return entries.length > 0 ? entries.join(' | ') : '-'
|
||||
}
|
||||
|
||||
function resolveResourceStageLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
const labelMap: Record<string, string> = {
|
||||
resource_ready: '资源已准备',
|
||||
pending_prepare: '待准备资源',
|
||||
pending_payment: '待支付',
|
||||
pending_binding_prepare: '待准备资源',
|
||||
retry_pending: '待重试',
|
||||
manual_review: '人工处理',
|
||||
resource_exception: '资源异常',
|
||||
closed: '已关闭',
|
||||
expired: '已过期',
|
||||
completed: '已完成',
|
||||
not_started: '未开始',
|
||||
}
|
||||
|
||||
return labelMap[normalized] || normalized || '-'
|
||||
}
|
||||
|
||||
function resolveCustomerStageLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
const labelMap: Record<string, string> = {
|
||||
not_started: '待打开链接',
|
||||
waiting_customer: '待客户绑定',
|
||||
link_opened: '已打开链接',
|
||||
customer_processing: '处理中',
|
||||
customer_confirmed: '已提交',
|
||||
customer_completed: '客户已完成',
|
||||
customer_exception: '客户步骤异常',
|
||||
closed: '已关闭',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
return labelMap[normalized] || normalized || '-'
|
||||
}
|
||||
|
||||
function resolveStageTone(status: string): TagType {
|
||||
const normalized = String(status || '').trim()
|
||||
if (['resource_ready', 'customer_completed', 'completed'].includes(normalized)) return 'success'
|
||||
if (
|
||||
[
|
||||
'waiting_customer',
|
||||
'link_opened',
|
||||
'customer_confirmed',
|
||||
'customer_processing',
|
||||
].includes(normalized)
|
||||
) return 'primary'
|
||||
if (['resource_exception', 'customer_exception', 'manual_review', 'failed'].includes(normalized)) return 'danger'
|
||||
if (['pending_prepare', 'pending_binding_prepare', 'retry_pending'].includes(normalized)) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function resolveOrderProgressText(summary: AdminOrderDetail['order']['fulfillmentProgress']) {
|
||||
if (!summary.totalTaskCount) return '未生成履约任务'
|
||||
if (summary.completedTaskCount >= summary.totalTaskCount) return '履约完成'
|
||||
if (summary.preparedTaskCount > 0) return '等待客户绑定'
|
||||
return '待准备领取链接'
|
||||
}
|
||||
|
||||
function resolveOrderProgressTone(summary: AdminOrderDetail['order']['fulfillmentProgress']): TagType {
|
||||
if (!summary.totalTaskCount) return 'warning'
|
||||
if (summary.completedTaskCount >= summary.totalTaskCount) return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<AdminPageHeader title="订单详情" description="查看 91 订单、核销店铺、商品和自动履约任务。" />
|
||||
|
||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" />
|
||||
|
||||
<el-skeleton v-if="loading" :rows="8" animated />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<el-card shadow="never" class="section-card">
|
||||
<section class="order-hero">
|
||||
<div class="order-hero-main">
|
||||
<h2>{{ detail.order.platformOrderId }}</h2>
|
||||
<p class="subtitle">
|
||||
{{ detail.order.provider }} / {{ detail.order.platform }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="status-stack">
|
||||
<AdminStatusTag :status="detail.order.payStatus" />
|
||||
<AdminStatusTag :status="detail.order.orderStatus" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="summary-grid">
|
||||
<div class="summary-block summary-block--wide">
|
||||
<span>91 商品</span>
|
||||
<strong>{{ detail.order.itemSummary || '-' }}</strong>
|
||||
<small>用于匹配 cloudtentacles 商品或覆盖规则</small>
|
||||
</div>
|
||||
<div class="summary-block">
|
||||
<span>核销店铺</span>
|
||||
<strong>{{ detail.order.shopName || detail.order.shopId || '-' }}</strong>
|
||||
<small>shopId:{{ detail.order.shopId || '-' }}</small>
|
||||
</div>
|
||||
<div class="summary-block">
|
||||
<span>订单金额</span>
|
||||
<strong>{{ detail.order.totalAmount }} {{ detail.order.currency }}</strong>
|
||||
<small>支付:{{ formatAdminDateTime(detail.order.paidAt) }}</small>
|
||||
</div>
|
||||
<div class="summary-block">
|
||||
<span>履约进度</span>
|
||||
<el-tag :type="resolveOrderProgressTone(detail.order.fulfillmentProgress)" effect="light">
|
||||
{{ resolveOrderProgressText(detail.order.fulfillmentProgress) }}
|
||||
</el-tag>
|
||||
<small>
|
||||
完成 {{ detail.order.fulfillmentProgress.completedTaskCount }}/{{ detail.order.fulfillmentProgress.totalTaskCount }}
|
||||
· 资源 {{ detail.order.fulfillmentProgress.preparedTaskCount }}/{{ detail.order.fulfillmentProgress.totalTaskCount }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="order-facts">
|
||||
<div>
|
||||
<dt>内部订单</dt>
|
||||
<dd>{{ detail.order.orderId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>创建时间</dt>
|
||||
<dd>{{ formatAdminDateTime(detail.order.createdAt) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新时间</dt>
|
||||
<dd>{{ formatAdminDateTime(detail.order.updatedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>履约任务</h3>
|
||||
<el-table :data="detail.tasks" size="small">
|
||||
<el-table-column label="任务号">
|
||||
<template #default="{ row }">
|
||||
<RouterLink :to="`/admin/tasks/${row.taskId}`">{{ row.taskNo }}</RouterLink>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态">
|
||||
<template #default="{ row }">
|
||||
<AdminStatusTag :status="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="资源准备">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="resolveStageTone(row.resourceStatus)" effect="light" size="small">
|
||||
{{ resolveResourceStageLabel(row.resourceStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户步骤">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="resolveStageTone(row.customerStatus)" effect="light" size="small">
|
||||
{{ resolveCustomerStageLabel(row.customerStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误">
|
||||
<template #default="{ row }">{{ row.lastError || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>商品子项</h3>
|
||||
<el-table :data="detail.items" size="small">
|
||||
<el-table-column prop="skuName" label="91 商品" min-width="220" />
|
||||
<el-table-column prop="skuCode" label="匹配标识" min-width="180" />
|
||||
<el-table-column prop="quantity" label="数量" width="80" />
|
||||
<el-table-column label="规格摘要">
|
||||
<template #default="{ row }">{{ formatSpecSummary(row.spec) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>原始载荷</h3>
|
||||
<details class="payload-box">
|
||||
<summary>展开查看原始订单 JSON</summary>
|
||||
<pre>{{ formatJsonBlock(detail.order.rawPayload) }}</pre>
|
||||
</details>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-empty v-else description="订单不存在或已被删除" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.section-card h3 {
|
||||
margin: 0 0 var(--space-4);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.order-hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.order-hero h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1.4fr) repeat(3, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.summary-block {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 112px;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.summary-block--wide {
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.summary-block span,
|
||||
.summary-block small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-block span {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-block strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.35;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.summary-block small {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.summary-block :deep(.el-tag) {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.order-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--border-default);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.order-facts div {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px var(--space-4);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.order-facts dt,
|
||||
.order-facts dd {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.order-facts dt {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-facts dd {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.payload-box summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.payload-box pre {
|
||||
margin: var(--space-3) 0 0;
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
border-radius: var(--radius-md);
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.summary-block--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.order-hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.order-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary-block--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.order-facts {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,471 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { useAdminListPage } from "@/composables/useAdminListPage";
|
||||
import { useAdminListQuerySync } from "@/composables/useAdminListQuerySync";
|
||||
import AdminPageHeader from "@/components/admin/AdminPageHeader.vue";
|
||||
import AdminStatusTag from "@/components/admin/AdminStatusTag.vue";
|
||||
import { fetchAdminOrders } from "@/services/admin";
|
||||
import type { AdminOrderListItem } from "@/types/admin";
|
||||
import { adminPayStatusOptions } from "@/utils/admin-options";
|
||||
import { formatAdminDateTime } from "@/utils/admin-time";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { readQueryString, readQueryPage, syncRouteQuery } =
|
||||
useAdminListQuerySync(route, router);
|
||||
|
||||
const platformOrderId = ref(readQueryString("platformOrderId"));
|
||||
const payStatus = ref(readQueryString("payStatus"));
|
||||
const skuCode = ref(readQueryString("skuCode"));
|
||||
const dateRange = ref<string[]>(
|
||||
[readQueryString("dateFrom"), readQueryString("dateTo")].filter(Boolean)
|
||||
);
|
||||
|
||||
const {
|
||||
loading,
|
||||
errorMessage,
|
||||
items,
|
||||
pagination,
|
||||
loadPage: loadOrders,
|
||||
} = useAdminListPage<AdminOrderListItem>({
|
||||
defaultErrorMessage: "读取订单列表失败",
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchAdminOrders({
|
||||
page,
|
||||
pageSize,
|
||||
platformOrderId: platformOrderId.value.trim(),
|
||||
payStatus: payStatus.value.trim(),
|
||||
skuCode: skuCode.value.trim(),
|
||||
dateFrom: dateRange.value[0] || "",
|
||||
dateTo: dateRange.value[1] || "",
|
||||
}),
|
||||
});
|
||||
|
||||
const summary = computed(() => ({
|
||||
paidCount: items.value.filter((item) => item.payStatus === "paid").length,
|
||||
refundedCount: items.value.filter((item) => item.payStatus === "refunded")
|
||||
.length,
|
||||
completedCount: items.value.filter(
|
||||
(item) =>
|
||||
item.taskCount > 0 &&
|
||||
item.completedTaskCount >= item.taskCount &&
|
||||
item.payStatus === "paid"
|
||||
).length,
|
||||
issueCount: items.value.filter((item) => hasOrderIssue(item)).length,
|
||||
}));
|
||||
|
||||
function resetFilters() {
|
||||
platformOrderId.value = "";
|
||||
payStatus.value = "";
|
||||
skuCode.value = "";
|
||||
dateRange.value = [];
|
||||
void applyFilters(1);
|
||||
}
|
||||
|
||||
async function applyFilters(page = 1) {
|
||||
await syncRouteQuery(page, {
|
||||
platformOrderId: platformOrderId.value,
|
||||
payStatus: payStatus.value,
|
||||
skuCode: skuCode.value,
|
||||
dateFrom: dateRange.value[0] || "",
|
||||
dateTo: dateRange.value[1] || "",
|
||||
});
|
||||
await loadOrders(page);
|
||||
}
|
||||
|
||||
function resolveShopLabel(item: AdminOrderListItem) {
|
||||
return item.shopName || item.shopId || "未识别店铺";
|
||||
}
|
||||
|
||||
function resolveItemSummary(item: AdminOrderListItem) {
|
||||
return item.itemSummary || "未识别商品";
|
||||
}
|
||||
|
||||
function hasOrderIssue(item: AdminOrderListItem) {
|
||||
return (
|
||||
["refunded", "failed", "unpaid"].includes(item.payStatus) ||
|
||||
["resource_exception", "manual_review", "retry_pending"].includes(item.resourceStatus) ||
|
||||
item.customerStatus === "customer_exception"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveFollowUpText(item: AdminOrderListItem) {
|
||||
if (item.payStatus === "unpaid") return "待支付";
|
||||
if (item.payStatus === "refunded") return "已退款";
|
||||
if (item.payStatus === "failed") return "支付失败";
|
||||
if (
|
||||
item.customerStatus === "customer_exception" ||
|
||||
["resource_exception", "manual_review", "retry_pending"].includes(item.resourceStatus)
|
||||
) {
|
||||
return "履约异常";
|
||||
}
|
||||
if (item.taskCount <= 0) return "未生成履约任务";
|
||||
if (item.completedTaskCount >= item.taskCount) return "履约完成";
|
||||
if (item.preparedTaskCount > 0) return "等待客户绑定";
|
||||
return "待准备领取链接";
|
||||
}
|
||||
|
||||
function resolveFollowUpTone(item: AdminOrderListItem) {
|
||||
if (hasOrderIssue(item)) return "danger";
|
||||
if (item.taskCount <= 0 || item.completedTaskCount < item.taskCount)
|
||||
return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
function resolveResourceStageLabel(status: string) {
|
||||
const normalized = String(status || "").trim();
|
||||
const labelMap: Record<string, string> = {
|
||||
resource_ready: "资源已准备",
|
||||
pending_prepare: "待准备资源",
|
||||
pending_payment: "待支付",
|
||||
pending_binding_prepare: "待准备资源",
|
||||
retry_pending: "待重试",
|
||||
manual_review: "人工处理",
|
||||
resource_exception: "资源异常",
|
||||
closed: "已关闭",
|
||||
expired: "已过期",
|
||||
completed: "已完成",
|
||||
not_started: "未开始",
|
||||
};
|
||||
|
||||
return labelMap[normalized] || normalized || "-";
|
||||
}
|
||||
|
||||
function resolveCustomerStageLabel(status: string) {
|
||||
const normalized = String(status || "").trim();
|
||||
const labelMap: Record<string, string> = {
|
||||
not_started: "待打开链接",
|
||||
waiting_customer: "待客户绑定",
|
||||
link_opened: "已打开链接",
|
||||
customer_processing: "处理中",
|
||||
customer_confirmed: "已提交",
|
||||
customer_completed: "客户已完成",
|
||||
customer_exception: "客户步骤异常",
|
||||
closed: "已关闭",
|
||||
expired: "已过期",
|
||||
};
|
||||
|
||||
return labelMap[normalized] || normalized || "-";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void applyFilters(readQueryPage());
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="orders-page list-page">
|
||||
<AdminPageHeader
|
||||
title="订单列表"
|
||||
description="查看 91 订单、核销店铺、商品和自动履约进度。"
|
||||
>
|
||||
<template #extra>
|
||||
<span class="total-badge">共 {{ pagination.total }} 条订单</span>
|
||||
</template>
|
||||
</AdminPageHeader>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">查询订单</span>
|
||||
<span class="card-desc"
|
||||
>按订单号、支付状态、商品名和日期范围筛选。</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-grid">
|
||||
<el-input
|
||||
v-model="platformOrderId"
|
||||
class="filter-control"
|
||||
placeholder="平台订单号(模糊匹配)"
|
||||
clearable
|
||||
@keyup.enter="applyFilters(1)"
|
||||
/>
|
||||
<el-select
|
||||
v-model="payStatus"
|
||||
class="filter-control"
|
||||
placeholder="支付状态"
|
||||
clearable
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in adminPayStatusOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="skuCode"
|
||||
class="filter-control"
|
||||
placeholder="商品名 / SKU"
|
||||
clearable
|
||||
@keyup.enter="applyFilters(1)"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
class="filter-control"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
range-separator="至"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<div class="filter-buttons">
|
||||
<el-button round @click="resetFilters">重置</el-button>
|
||||
<el-button round type="primary" @click="applyFilters(1)"
|
||||
>查询</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="!loading && items.length > 0" class="summary-strip">
|
||||
<span class="summary-title">当前页</span>
|
||||
<span class="summary-metric" data-tone="success"
|
||||
>已支付 <strong>{{ summary.paidCount }}</strong></span
|
||||
>
|
||||
<span class="summary-metric" data-tone="danger"
|
||||
>已退款 <strong>{{ summary.refundedCount }}</strong></span
|
||||
>
|
||||
<span class="summary-metric" data-tone="primary"
|
||||
>已完成 <strong>{{ summary.completedCount }}</strong></span
|
||||
>
|
||||
<span
|
||||
class="summary-metric"
|
||||
:data-tone="summary.issueCount > 0 ? 'warning' : 'muted'"
|
||||
>需关注 <strong>{{ summary.issueCount }}</strong></span
|
||||
>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<el-card
|
||||
shadow="never"
|
||||
class="section-card"
|
||||
v-loading="loading"
|
||||
element-loading-text="订单列表加载中"
|
||||
>
|
||||
<template v-if="items.length === 0 && !loading">
|
||||
<el-empty description="当前筛选条件下暂无订单" :image-size="60" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="orders-toolbar table-toolbar">
|
||||
<strong>订单明细</strong>
|
||||
<span
|
||||
>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="items"
|
||||
stripe
|
||||
size="small"
|
||||
class="orders-table data-table"
|
||||
>
|
||||
<el-table-column label="订单信息" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<RouterLink
|
||||
:to="`/admin/orders/${row.orderId}`"
|
||||
class="order-no id-link"
|
||||
:title="row.platformOrderId"
|
||||
>
|
||||
{{ row.platformOrderId }}
|
||||
</RouterLink>
|
||||
<div class="order-meta-block">
|
||||
<span class="source-badge">{{ row.platform || "-" }}</span>
|
||||
<span class="order-meta-text" :title="resolveShopLabel(row)">
|
||||
核销店铺 {{ resolveShopLabel(row) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品 / 任务" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title" :title="resolveItemSummary(row)">{{
|
||||
resolveItemSummary(row)
|
||||
}}</span>
|
||||
<span class="cell-subline"
|
||||
>{{ row.itemCount }} 项 · 合计 {{ row.totalQuantity }} 件</span
|
||||
>
|
||||
<span class="cell-subline">履约任务 {{ row.taskCount }} 个</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div class="order-status-summary">
|
||||
<span
|
||||
class="follow-up-chip"
|
||||
:data-tone="resolveFollowUpTone(row)"
|
||||
>{{ resolveFollowUpText(row) }}</span
|
||||
>
|
||||
<div class="status-note-line">
|
||||
<span>支付</span>
|
||||
<AdminStatusTag :status="row.payStatus" :show-raw="false" />
|
||||
</div>
|
||||
<p class="status-note">
|
||||
客户 {{ resolveCustomerStageLabel(row.customerStatus) }}
|
||||
· 资源 {{ resolveResourceStageLabel(row.resourceStatus) }}
|
||||
· {{ row.completedTaskCount }}/{{ row.taskCount }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额 / 时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title amount-text"
|
||||
>{{ row.totalAmount }} {{ row.currency }}</span
|
||||
>
|
||||
<span class="cell-subline"
|
||||
>支付 {{ formatAdminDateTime(row.paidAt) }}</span
|
||||
>
|
||||
<span class="cell-subline"
|
||||
>更新 {{ formatAdminDateTime(row.updatedAt) }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<RouterLink
|
||||
:to="`/admin/orders/${row.orderId}`"
|
||||
class="detail-link"
|
||||
>详情</RouterLink
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="pagination.total > 0"
|
||||
v-model:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
class="pagination-center"
|
||||
@current-change="applyFilters"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import "../../../styles/admin-list-pages.css";
|
||||
|
||||
.filter-grid {
|
||||
grid-template-columns:
|
||||
minmax(200px, 1fr) minmax(130px, 0.6fr) minmax(180px, 0.9fr)
|
||||
minmax(320px, 1.4fr) minmax(150px, 0.55fr);
|
||||
}
|
||||
|
||||
.amount-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-meta-block {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, max-content) minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-meta-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-status-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-note-line {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.status-note-line :deep(.el-tag) {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.status-note {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.follow-up-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
min-height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.follow-up-chip[data-tone="success"] {
|
||||
color: #0f766e;
|
||||
background: var(--color-success-light);
|
||||
border-color: #a6f4c5;
|
||||
}
|
||||
.follow-up-chip[data-tone="warning"] {
|
||||
color: #b54708;
|
||||
background: var(--color-warning-light);
|
||||
border-color: #fedf89;
|
||||
}
|
||||
.follow-up-chip[data-tone="danger"] {
|
||||
color: var(--color-danger-dark);
|
||||
background: var(--color-danger-light);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.filter-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,539 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { RefreshRight } from '@element-plus/icons-vue'
|
||||
|
||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||
import {
|
||||
fetchAdminCloudtentaclesSourceConfig,
|
||||
fetchAdminKuaishouFeifeiConfig,
|
||||
fetchAdminKuaishouEticketSourceConfig,
|
||||
fetchAdminNotificationConfig,
|
||||
fetchAdminScheduledJobsConfig,
|
||||
} from '@/services/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import AdminPlatformCloudtentaclesSection from './components/cloudtentacles/AdminPlatformCloudtentaclesSection.vue'
|
||||
import AdminPlatformNinetyoneSection from './components/AdminPlatformNinetyoneSection.vue'
|
||||
import AdminPlatformNotificationSection from './components/notification/AdminPlatformNotificationSection.vue'
|
||||
import AdminPlatformKuaishouEticketSection from './components/kuaishou-eticket/AdminPlatformKuaishouEticketSection.vue'
|
||||
import AdminPlatformKuaishouFeifeiSection from './components/kuaishou-feifei/AdminPlatformKuaishouFeifeiSection.vue'
|
||||
import { useAdminCloudtentaclesPlatform } from './composables/useAdminCloudtentaclesPlatform'
|
||||
import { useAdminNinetyonePlatform } from './composables/useAdminNinetyonePlatform'
|
||||
import { useAdminNotificationPlatform } from './composables/useAdminNotificationPlatform'
|
||||
import { useAdminKuaishouEticketPlatform } from './composables/useAdminKuaishouEticketPlatform'
|
||||
import type { PlatformTab } from './composables/types'
|
||||
import type { AdminKuaishouFeifeiConfigResponse } from '@/types/admin'
|
||||
|
||||
const loading = ref(true)
|
||||
const errorMessage = ref('')
|
||||
const route = useRoute()
|
||||
|
||||
const platformTabs: PlatformTab[] = [
|
||||
'ninetyone',
|
||||
'notifications',
|
||||
'kuaishouEticket',
|
||||
'kuaishouFeifei',
|
||||
'cloudtentacles',
|
||||
]
|
||||
|
||||
function normalizePlatformTab(value: unknown): PlatformTab {
|
||||
return typeof value === 'string' && platformTabs.includes(value as PlatformTab)
|
||||
? (value as PlatformTab)
|
||||
: 'ninetyone'
|
||||
}
|
||||
|
||||
const activePlatform = computed(() => normalizePlatformTab(route.query.tab))
|
||||
const kuaishouFeifeiConfig = ref<AdminKuaishouFeifeiConfigResponse | null>(null)
|
||||
|
||||
const {
|
||||
ninetyoneLoading,
|
||||
ninetyoneActionLoadingId,
|
||||
ninetyoneResultError,
|
||||
ninetyoneStatus,
|
||||
ninetyoneOrders,
|
||||
ninetyoneStats,
|
||||
loadNinetyoneOrders,
|
||||
handleNinetyoneStatusChange,
|
||||
handleNinetyoneRetryOrder,
|
||||
handleNinetyoneFailOrder,
|
||||
} = useAdminNinetyonePlatform()
|
||||
|
||||
const {
|
||||
notificationFilePath,
|
||||
notificationForm,
|
||||
notificationRecipients,
|
||||
wpushRecipients,
|
||||
notificationSaving,
|
||||
notificationTesting,
|
||||
notificationResultError,
|
||||
notificationTestResult,
|
||||
scheduledJobsFilePath,
|
||||
scheduledJobsForm,
|
||||
scheduledJobs,
|
||||
scheduledJobRuntime,
|
||||
cloudtentaclesMonitorAccounts,
|
||||
scheduledJobsSaving,
|
||||
scheduledJobRunningId,
|
||||
notificationStats,
|
||||
hydrateNotificationConfig,
|
||||
hydrateScheduledJobsConfig,
|
||||
addNotificationRecipient,
|
||||
removeNotificationRecipient,
|
||||
handleNotificationSaveConfig,
|
||||
handleNotificationTest,
|
||||
handleScheduledJobsSaveConfig,
|
||||
handleScheduledJobRunNow,
|
||||
getScheduledJobRuntime,
|
||||
} = useAdminNotificationPlatform()
|
||||
|
||||
const {
|
||||
kuaishouEticketFilePath,
|
||||
kuaishouEticketShops,
|
||||
kuaishouEticketForm,
|
||||
kuaishouEticketSaving,
|
||||
kuaishouEticketResolvingShopId,
|
||||
kuaishouEticketDetailing,
|
||||
kuaishouEticketConsuming,
|
||||
kuaishouEticketResultError,
|
||||
kuaishouEticketShopInfoResult,
|
||||
kuaishouEticketDetailResult,
|
||||
kuaishouEticketConsumeResult,
|
||||
activeKuaishouEticketShop,
|
||||
kuaishouEticketStats,
|
||||
hydrateKuaishouEticketConfig,
|
||||
addKuaishouEticketShop,
|
||||
removeKuaishouEticketShop,
|
||||
setKuaishouEticketDebugShop,
|
||||
describeKuaishouEticketShop,
|
||||
getKuaishouEticketShopInitial,
|
||||
handleKuaishouEticketResolveShopInfo,
|
||||
handleKuaishouEticketSaveSource,
|
||||
handleKuaishouEticketQueryDetail,
|
||||
handleKuaishouEticketConsume,
|
||||
} = useAdminKuaishouEticketPlatform()
|
||||
|
||||
const {
|
||||
cloudtentaclesFilePath,
|
||||
cloudtentaclesSessionFilePath,
|
||||
cloudtentaclesForm,
|
||||
cloudtentaclesSaving,
|
||||
cloudtentaclesSendingSms,
|
||||
cloudtentaclesTesting,
|
||||
cloudtentaclesValidating,
|
||||
cloudtentaclesResultError,
|
||||
cloudtentaclesSmsResult,
|
||||
cloudtentaclesLoginResult,
|
||||
cloudtentaclesValidateResult,
|
||||
cloudtentaclesDebugLoading,
|
||||
cloudtentaclesDebugResult,
|
||||
cloudtentaclesPersistedSession,
|
||||
cloudtentaclesStats,
|
||||
sources: cloudtentaclesSources,
|
||||
sessions: cloudtentaclesSessions,
|
||||
selectedSourceKey: selectedCloudtentaclesSourceKey,
|
||||
currentSourceEnabled: currentCloudtentaclesSourceEnabled,
|
||||
currentSource: currentCloudtentaclesSource,
|
||||
currentSession: currentCloudtentaclesSession,
|
||||
hydrateCloudtentaclesConfig,
|
||||
switchSource: switchCloudtentaclesSource,
|
||||
addSource: addCloudtentaclesSource,
|
||||
removeSource: removeCloudtentaclesSource,
|
||||
handleCloudtentaclesSaveSource,
|
||||
handleCloudtentaclesSendSmsCode,
|
||||
handleCloudtentaclesTestLogin,
|
||||
handleCloudtentaclesValidateSession,
|
||||
handleCloudtentaclesFetchAsset,
|
||||
handleCloudtentaclesFetchCategories,
|
||||
handleCloudtentaclesFetchSkuList,
|
||||
handleCloudtentaclesBuySku,
|
||||
handleCloudtentaclesUseSku,
|
||||
handleCloudtentaclesFetchKnapsack,
|
||||
handleCloudtentaclesFetchVnList,
|
||||
handleCloudtentaclesAppointVn,
|
||||
handleCloudtentaclesGenerateVnLoginCode,
|
||||
handleCloudtentaclesFetchVnCode,
|
||||
handleCloudtentaclesVerifyVnCode,
|
||||
handleCloudtentaclesFetchBindUrl,
|
||||
handleCloudtentaclesBackVn,
|
||||
handleCloudtentaclesRunFullFlow,
|
||||
} = useAdminCloudtentaclesPlatform()
|
||||
|
||||
const overviewCards = computed(() => {
|
||||
const feifeiSource = kuaishouFeifeiConfig.value?.source
|
||||
const feifeiEffective = kuaishouFeifeiConfig.value?.effective
|
||||
const feifeiEnabledRuleCount = (feifeiSource?.productRules || []).filter(
|
||||
(rule) => rule.enabled !== false,
|
||||
).length
|
||||
const feifeiReady = Boolean(
|
||||
feifeiEffective?.enabled &&
|
||||
feifeiEffective.hasAppKey &&
|
||||
feifeiEffective.hasAppSecret &&
|
||||
feifeiEnabledRuleCount > 0,
|
||||
)
|
||||
const cards = [
|
||||
{
|
||||
key: 'notifications',
|
||||
label: '内部通知',
|
||||
tag: '通知与监控',
|
||||
value:
|
||||
notificationStats.value.barkEnabledRecipientCount +
|
||||
notificationStats.value.wpushEnabledRecipientCount,
|
||||
unit: '启用接收人',
|
||||
meta: `Bark ${notificationStats.value.barkConfiguredRecipientCount} 人 · WPush ${notificationStats.value.wpushConfiguredRecipientCount} 人 · 最近成功 ${notificationStats.value.lastSuccessCount} 个`,
|
||||
status: notificationStats.value.ready ? '已就绪' : '待配置',
|
||||
},
|
||||
{
|
||||
key: 'ninetyone',
|
||||
label: '91卡券',
|
||||
tag: '来源订单',
|
||||
value: ninetyoneStats.value.pendingCount,
|
||||
unit: '待补全订单',
|
||||
meta: `当前 ${ninetyoneStats.value.currentCount} 条 · 已生成任务 ${ninetyoneStats.value.taskReadyCount} 条`,
|
||||
status: ninetyoneStats.value.pendingCount > 0 ? '待处理' : '正常',
|
||||
},
|
||||
{
|
||||
key: 'kuaishouEticket',
|
||||
label: '快手小店核销',
|
||||
tag: '店铺凭据',
|
||||
value: kuaishouEticketStats.value.configuredShopCount,
|
||||
unit: '已配置店铺',
|
||||
meta: `Cookie 就绪 ${kuaishouEticketStats.value.cookieReadyCount} 家 · 当前店铺 ${
|
||||
kuaishouEticketStats.value.selectedShopReady ? '可调试' : '待补全'
|
||||
}`,
|
||||
status: kuaishouEticketStats.value.cookieReadyCount > 0 ? '已就绪' : '待配置',
|
||||
},
|
||||
{
|
||||
key: 'kuaishouFeifei',
|
||||
label: 'kuaishou-feifei',
|
||||
tag: '补充发货平台',
|
||||
value: feifeiEnabledRuleCount,
|
||||
unit: '启用规则',
|
||||
meta: `API ${
|
||||
feifeiEffective?.hasAppKey && feifeiEffective?.hasAppSecret ? '已配置' : '待补全'
|
||||
} · 总规则 ${feifeiSource?.productRules.length || 0} 条`,
|
||||
status: feifeiReady ? '已就绪' : '待配置',
|
||||
},
|
||||
{
|
||||
key: 'cloudtentacles',
|
||||
label: 'cloudtentacles',
|
||||
tag: '履约账号',
|
||||
value: cloudtentaclesStats.value.hasCredential ? '已配置' : '未配置',
|
||||
unit: '履约凭据',
|
||||
meta: `短信 ${cloudtentaclesStats.value.smsSent ? '已发送' : '未发送'} · 会话 ${
|
||||
cloudtentaclesStats.value.validated ? '已验证' : '未验证'
|
||||
}`,
|
||||
status: cloudtentaclesStats.value.validated ? '已验证' : '待验证',
|
||||
},
|
||||
]
|
||||
|
||||
return cards
|
||||
})
|
||||
|
||||
const activeOverview = computed(
|
||||
() =>
|
||||
overviewCards.value.find((card) => card.key === activePlatform.value) || overviewCards.value[0],
|
||||
)
|
||||
|
||||
async function loadConfigs() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const [
|
||||
notificationResponse,
|
||||
scheduledJobsResponse,
|
||||
kuaishouEticketResponse,
|
||||
kuaishouFeifeiResponse,
|
||||
cloudtentaclesResponse,
|
||||
] = await Promise.all([
|
||||
fetchAdminNotificationConfig(),
|
||||
fetchAdminScheduledJobsConfig(),
|
||||
fetchAdminKuaishouEticketSourceConfig(),
|
||||
fetchAdminKuaishouFeifeiConfig(),
|
||||
fetchAdminCloudtentaclesSourceConfig(),
|
||||
])
|
||||
|
||||
hydrateNotificationConfig(notificationResponse.data)
|
||||
hydrateScheduledJobsConfig(scheduledJobsResponse.data)
|
||||
hydrateKuaishouEticketConfig(kuaishouEticketResponse.data)
|
||||
kuaishouFeifeiConfig.value = kuaishouFeifeiResponse.data
|
||||
hydrateCloudtentaclesConfig(cloudtentaclesResponse.data)
|
||||
await loadNinetyoneOrders(1)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取平台配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadConfigs)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="platform-page">
|
||||
<AdminPageHeader title="平台配置" description="按平台分别管理来源接入、凭据与店铺能力。">
|
||||
<template #extra>
|
||||
<el-button circle title="刷新全部配置" @click="loadConfigs">
|
||||
<template #icon
|
||||
><el-icon><RefreshRight /></el-icon
|
||||
></template>
|
||||
</el-button>
|
||||
</template>
|
||||
</AdminPageHeader>
|
||||
|
||||
<!-- 权限不足 -->
|
||||
<el-result
|
||||
v-if="!hasAdminRole('admin')"
|
||||
icon="warning"
|
||||
title="仅管理员可以维护平台配置"
|
||||
sub-title="请切换管理员账号登录"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<!-- 错误提示 -->
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- 加载态 -->
|
||||
<el-skeleton v-if="loading" :rows="8" animated />
|
||||
|
||||
<template v-else>
|
||||
<main class="platform-detail">
|
||||
<section v-if="activeOverview" class="active-summary">
|
||||
<div class="active-summary-copy">
|
||||
<div class="summary-kicker">{{ activeOverview.tag }}</div>
|
||||
<h2>{{ activeOverview.label }}</h2>
|
||||
<p>{{ activeOverview.meta }}</p>
|
||||
</div>
|
||||
<div class="summary-metrics">
|
||||
<div class="summary-metric">
|
||||
<span>{{ activeOverview.unit }}</span>
|
||||
<strong>{{ activeOverview.value }}</strong>
|
||||
</div>
|
||||
<el-tag size="large" effect="plain">{{ activeOverview.status }}</el-tag>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AdminPlatformNinetyoneSection
|
||||
v-if="activePlatform === 'ninetyone'"
|
||||
:ninetyone-loading="ninetyoneLoading"
|
||||
:ninetyone-action-loading-id="ninetyoneActionLoadingId"
|
||||
:ninetyone-result-error="ninetyoneResultError"
|
||||
:ninetyone-status="ninetyoneStatus"
|
||||
:ninetyone-orders="ninetyoneOrders"
|
||||
:ninetyone-stats="ninetyoneStats"
|
||||
:load-ninetyone-orders="loadNinetyoneOrders"
|
||||
:handle-ninetyone-status-change="handleNinetyoneStatusChange"
|
||||
:handle-ninetyone-retry-order="handleNinetyoneRetryOrder"
|
||||
:handle-ninetyone-fail-order="handleNinetyoneFailOrder"
|
||||
/>
|
||||
|
||||
<AdminPlatformNotificationSection
|
||||
v-else-if="activePlatform === 'notifications'"
|
||||
:notification-file-path="notificationFilePath"
|
||||
:notification-form="notificationForm"
|
||||
:notification-recipients="notificationRecipients"
|
||||
:wpush-recipients="wpushRecipients"
|
||||
:notification-saving="notificationSaving"
|
||||
:notification-testing="notificationTesting"
|
||||
:notification-result-error="notificationResultError"
|
||||
:notification-test-result="notificationTestResult"
|
||||
:scheduled-jobs-file-path="scheduledJobsFilePath"
|
||||
:scheduled-jobs-form="scheduledJobsForm"
|
||||
:scheduled-jobs="scheduledJobs"
|
||||
:scheduled-job-runtime="scheduledJobRuntime"
|
||||
:cloudtentacles-monitor-accounts="cloudtentaclesMonitorAccounts"
|
||||
:scheduled-jobs-saving="scheduledJobsSaving"
|
||||
:scheduled-job-running-id="scheduledJobRunningId"
|
||||
:notification-stats="notificationStats"
|
||||
:add-notification-recipient="addNotificationRecipient"
|
||||
:remove-notification-recipient="removeNotificationRecipient"
|
||||
:handle-notification-save-config="handleNotificationSaveConfig"
|
||||
:handle-notification-test="handleNotificationTest"
|
||||
:handle-scheduled-jobs-save-config="handleScheduledJobsSaveConfig"
|
||||
:handle-scheduled-job-run-now="handleScheduledJobRunNow"
|
||||
:get-scheduled-job-runtime="getScheduledJobRuntime"
|
||||
/>
|
||||
|
||||
<AdminPlatformKuaishouEticketSection
|
||||
v-else-if="activePlatform === 'kuaishouEticket'"
|
||||
:kuaishou-eticket-file-path="kuaishouEticketFilePath"
|
||||
:kuaishou-eticket-shops="kuaishouEticketShops"
|
||||
:kuaishou-eticket-form="kuaishouEticketForm"
|
||||
:kuaishou-eticket-saving="kuaishouEticketSaving"
|
||||
:kuaishou-eticket-resolving-shop-id="kuaishouEticketResolvingShopId"
|
||||
:kuaishou-eticket-detailing="kuaishouEticketDetailing"
|
||||
:kuaishou-eticket-consuming="kuaishouEticketConsuming"
|
||||
:kuaishou-eticket-result-error="kuaishouEticketResultError"
|
||||
:kuaishou-eticket-shop-info-result="kuaishouEticketShopInfoResult"
|
||||
:kuaishou-eticket-detail-result="kuaishouEticketDetailResult"
|
||||
:kuaishou-eticket-consume-result="kuaishouEticketConsumeResult"
|
||||
:active-kuaishou-eticket-shop="activeKuaishouEticketShop"
|
||||
:kuaishou-eticket-stats="kuaishouEticketStats"
|
||||
:add-kuaishou-eticket-shop="addKuaishouEticketShop"
|
||||
:remove-kuaishou-eticket-shop="removeKuaishouEticketShop"
|
||||
:set-kuaishou-eticket-debug-shop="setKuaishouEticketDebugShop"
|
||||
:describe-kuaishou-eticket-shop="describeKuaishouEticketShop"
|
||||
:get-kuaishou-eticket-shop-initial="getKuaishouEticketShopInitial"
|
||||
:handle-kuaishou-eticket-resolve-shop-info="handleKuaishouEticketResolveShopInfo"
|
||||
:handle-kuaishou-eticket-save-source="handleKuaishouEticketSaveSource"
|
||||
:handle-kuaishou-eticket-query-detail="handleKuaishouEticketQueryDetail"
|
||||
:handle-kuaishou-eticket-consume="handleKuaishouEticketConsume"
|
||||
/>
|
||||
|
||||
<AdminPlatformKuaishouFeifeiSection
|
||||
v-else-if="activePlatform === 'kuaishouFeifei'"
|
||||
@loaded="kuaishouFeifeiConfig = $event"
|
||||
@saved="kuaishouFeifeiConfig = $event"
|
||||
/>
|
||||
|
||||
<AdminPlatformCloudtentaclesSection
|
||||
v-else-if="activePlatform === 'cloudtentacles'"
|
||||
:cloudtentacles-file-path="cloudtentaclesFilePath"
|
||||
:cloudtentacles-session-file-path="cloudtentaclesSessionFilePath"
|
||||
:cloudtentacles-form="cloudtentaclesForm"
|
||||
:cloudtentacles-saving="cloudtentaclesSaving"
|
||||
:cloudtentacles-sending-sms="cloudtentaclesSendingSms"
|
||||
:cloudtentacles-testing="cloudtentaclesTesting"
|
||||
:cloudtentacles-validating="cloudtentaclesValidating"
|
||||
:cloudtentacles-result-error="cloudtentaclesResultError"
|
||||
:cloudtentacles-sms-result="cloudtentaclesSmsResult"
|
||||
:cloudtentacles-login-result="cloudtentaclesLoginResult"
|
||||
:cloudtentacles-validate-result="cloudtentaclesValidateResult"
|
||||
:cloudtentacles-debug-loading="cloudtentaclesDebugLoading"
|
||||
:cloudtentacles-debug-result="cloudtentaclesDebugResult"
|
||||
:cloudtentacles-persisted-session="cloudtentaclesPersistedSession"
|
||||
:cloudtentacles-stats="cloudtentaclesStats"
|
||||
:sources="cloudtentaclesSources"
|
||||
:sessions="cloudtentaclesSessions"
|
||||
:selected-source-key="selectedCloudtentaclesSourceKey"
|
||||
:current-source-enabled="currentCloudtentaclesSourceEnabled"
|
||||
:current-source="currentCloudtentaclesSource"
|
||||
:current-session="currentCloudtentaclesSession"
|
||||
:switch-source="switchCloudtentaclesSource"
|
||||
:add-source="addCloudtentaclesSource"
|
||||
:remove-source="removeCloudtentaclesSource"
|
||||
:handle-cloudtentacles-save-source="handleCloudtentaclesSaveSource"
|
||||
:handle-cloudtentacles-send-sms-code="handleCloudtentaclesSendSmsCode"
|
||||
:handle-cloudtentacles-test-login="handleCloudtentaclesTestLogin"
|
||||
:handle-cloudtentacles-validate-session="handleCloudtentaclesValidateSession"
|
||||
:handle-cloudtentacles-fetch-asset="handleCloudtentaclesFetchAsset"
|
||||
:handle-cloudtentacles-fetch-categories="handleCloudtentaclesFetchCategories"
|
||||
:handle-cloudtentacles-fetch-sku-list="handleCloudtentaclesFetchSkuList"
|
||||
:handle-cloudtentacles-buy-sku="handleCloudtentaclesBuySku"
|
||||
:handle-cloudtentacles-use-sku="handleCloudtentaclesUseSku"
|
||||
:handle-cloudtentacles-fetch-knapsack="handleCloudtentaclesFetchKnapsack"
|
||||
:handle-cloudtentacles-fetch-vn-list="handleCloudtentaclesFetchVnList"
|
||||
:handle-cloudtentacles-appoint-vn="handleCloudtentaclesAppointVn"
|
||||
:handle-cloudtentacles-generate-vn-login-code="handleCloudtentaclesGenerateVnLoginCode"
|
||||
:handle-cloudtentacles-fetch-vn-code="handleCloudtentaclesFetchVnCode"
|
||||
:handle-cloudtentacles-verify-vn-code="handleCloudtentaclesVerifyVnCode"
|
||||
:handle-cloudtentacles-fetch-bind-url="handleCloudtentaclesFetchBindUrl"
|
||||
:handle-cloudtentacles-back-vn="handleCloudtentaclesBackVn"
|
||||
:handle-cloudtentacles-run-full-flow="handleCloudtentaclesRunFullFlow"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-page {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.platform-detail {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.active-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.active-summary-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.summary-kicker {
|
||||
color: #2563eb;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.active-summary h2 {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.active-summary p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.summary-metrics {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-metric {
|
||||
min-width: 112px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.summary-metric span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.summary-metric strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.active-summary {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.summary-metrics {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNinetyoneOrderItem, AdminNinetyoneOrderListResult } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type Props = {
|
||||
ninetyoneLoading: boolean
|
||||
ninetyoneActionLoadingId: number | null
|
||||
ninetyoneResultError: string
|
||||
ninetyoneStatus: 'pending_config' | 'all' | 'manual_failed'
|
||||
ninetyoneOrders: AdminNinetyoneOrderListResult
|
||||
ninetyoneStats: {
|
||||
total: number
|
||||
currentCount: number
|
||||
pendingCount: number
|
||||
failedCount: number
|
||||
taskReadyCount: number
|
||||
}
|
||||
loadNinetyoneOrders: (page?: number) => void | Promise<void>
|
||||
handleNinetyoneStatusChange: (
|
||||
status: 'pending_config' | 'all' | 'manual_failed',
|
||||
) => void | Promise<void>
|
||||
handleNinetyoneRetryOrder: (item: AdminNinetyoneOrderItem) => void | Promise<void>
|
||||
handleNinetyoneFailOrder: (item: AdminNinetyoneOrderItem) => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="platform-section">
|
||||
<!-- 订单队列 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">91卡券订单队列</span>
|
||||
<p class="card-desc">
|
||||
当前筛选 {{ ninetyoneStats.currentCount }} 条,总计
|
||||
{{ ninetyoneStats.total }} 条,已生成任务 {{ ninetyoneStats.taskReadyCount }} 条。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-radio-group
|
||||
:model-value="ninetyoneStatus"
|
||||
size="small"
|
||||
@update:model-value="
|
||||
(v: string | number | boolean | undefined) =>
|
||||
handleNinetyoneStatusChange(v as 'pending_config' | 'all' | 'manual_failed')
|
||||
"
|
||||
>
|
||||
<el-radio-button value="pending_config">待补全</el-radio-button>
|
||||
<el-radio-button value="manual_failed">失败</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button
|
||||
:loading="ninetyoneLoading"
|
||||
@click="loadNinetyoneOrders(ninetyoneOrders.page)"
|
||||
>刷新</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="ninetyoneResultError"
|
||||
:title="ninetyoneResultError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-table
|
||||
:data="ninetyoneOrders.items"
|
||||
stripe
|
||||
size="small"
|
||||
v-loading="ninetyoneLoading"
|
||||
element-loading-text="正在读取 91卡券订单..."
|
||||
>
|
||||
<el-table-column label="订单" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div class="fw-600">{{ row.orderNo }}</div>
|
||||
<div class="text-muted-xs">
|
||||
{{ row.outTradeNo || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div class="fw-600">{{ row.productName || row.productNo || '-' }}</div>
|
||||
<div class="text-muted-xs">productNo: {{ row.productNo || '-' }}</div>
|
||||
<div class="text-muted-xs">数量:{{ row.buyNum || 0 }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<el-tag size="small">{{ row.orderStatus }}</el-tag>
|
||||
<div class="text-muted-xs mt-1">任务 {{ row.taskCount }}</div>
|
||||
<div v-if="row.failReason" class="text-xs text-danger">
|
||||
{{ row.failReason }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }">{{
|
||||
formatAdminDateTime(row.updatedAt || row.createdAt)
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
:loading="ninetyoneActionLoadingId === row.orderId"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleNinetyoneRetryOrder(row)"
|
||||
>
|
||||
重试生成任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.orderStatus !== 'manual_failed'"
|
||||
:loading="ninetyoneActionLoadingId === row.orderId"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleNinetyoneFailOrder(row)"
|
||||
>
|
||||
标记失败
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="当前没有 91卡券订单。" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
-623
@@ -1,623 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AdminCloudtentaclesLoginTestResult,
|
||||
AdminCloudtentaclesPersistedSession,
|
||||
AdminCloudtentaclesSendSmsResult,
|
||||
AdminCloudtentaclesSessionsMap,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
AdminCloudtentaclesValidateSessionResult,
|
||||
} from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { useRememberedCollapse } from '../../composables/useRememberedCollapse'
|
||||
|
||||
type CloudtentaclesForm = {
|
||||
baseUrl: string
|
||||
username: string
|
||||
password: string
|
||||
phone: string
|
||||
code: string
|
||||
token: string
|
||||
vnKey: string
|
||||
vnId: number
|
||||
skuId: number
|
||||
skuCount: number
|
||||
deviceId: string
|
||||
deviceType: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
cloudtentaclesForm: CloudtentaclesForm
|
||||
cloudtentaclesSaving: boolean
|
||||
cloudtentaclesSendingSms: boolean
|
||||
cloudtentaclesTesting: boolean
|
||||
cloudtentaclesValidating: boolean
|
||||
cloudtentaclesResultError: string
|
||||
cloudtentaclesSmsResult: AdminCloudtentaclesSendSmsResult | null
|
||||
cloudtentaclesLoginResult: AdminCloudtentaclesLoginTestResult | null
|
||||
cloudtentaclesValidateResult: AdminCloudtentaclesValidateSessionResult | null
|
||||
selectedSourceKey: string
|
||||
currentSourceEnabled: boolean
|
||||
sources: AdminCloudtentaclesSourceItem[]
|
||||
sessions: AdminCloudtentaclesSessionsMap
|
||||
currentSource: AdminCloudtentaclesSourceItem | null
|
||||
currentSession: AdminCloudtentaclesPersistedSession | null
|
||||
handleCloudtentaclesSaveSource: () => void | Promise<void>
|
||||
handleCloudtentaclesSendSmsCode: () => void | Promise<void>
|
||||
handleCloudtentaclesTestLogin: () => void | Promise<void>
|
||||
handleCloudtentaclesValidateSession: () => void | Promise<void>
|
||||
switchSource: (sourceKey: string) => void
|
||||
addSource: () => void
|
||||
removeSource: (sourceKey: string) => void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const {
|
||||
activeNames: advancedCollapseNames,
|
||||
handleChange: handleAdvancedCollapseChange,
|
||||
} = useRememberedCollapse('admin:platform-config:cloudtentacles:config:collapse')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="cloud-config-card">
|
||||
<template #header>
|
||||
<div class="config-header">
|
||||
<div>
|
||||
<span class="card-title">cloudtentacles 履约平台配置</span>
|
||||
<p class="card-desc">维护账号、凭据与登录会话;调试字段默认折叠。</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="cloudtentaclesSaving"
|
||||
@click="handleCloudtentaclesSaveSource"
|
||||
>
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="crud-layout">
|
||||
<aside class="account-column">
|
||||
<div class="account-column-head">
|
||||
<div>
|
||||
<strong>账号列表</strong>
|
||||
<p>{{ sources.length }} 个账号</p>
|
||||
</div>
|
||||
<el-button type="primary" plain size="small" @click="addSource">新增账号</el-button>
|
||||
</div>
|
||||
|
||||
<div class="account-list">
|
||||
<button
|
||||
v-for="source in sources"
|
||||
:key="source.key"
|
||||
type="button"
|
||||
class="account-item"
|
||||
:class="{ 'account-item--active': source.key === selectedSourceKey }"
|
||||
@click="switchSource(source.key)"
|
||||
>
|
||||
<div class="account-item-head">
|
||||
<strong>{{ source.label || source.key }}</strong>
|
||||
<el-tag :type="source.enabled !== false ? 'success' : 'info'" size="small">
|
||||
{{ source.enabled !== false ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="account-item-meta">
|
||||
<span>{{ source.username || '账号未填' }}</span>
|
||||
<span>{{ source.phone || '手机号未填' }}</span>
|
||||
</div>
|
||||
<div class="account-item-foot">
|
||||
<span>{{ source.key }}</span>
|
||||
<span>{{
|
||||
sessions[source.key]
|
||||
? sessions[source.key].hasToken
|
||||
? '已登录'
|
||||
: '未登录'
|
||||
: '-'
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="editor-column">
|
||||
<div class="editor-head">
|
||||
<div>
|
||||
<strong>{{ currentSource?.label || currentSource?.key || '未选择账号' }}</strong>
|
||||
<p>
|
||||
{{
|
||||
currentSession?.hasToken
|
||||
? `最近登录:${formatAdminDateTime(currentSession.loggedInAt)}`
|
||||
: '当前还没有可用 token'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="editor-head-actions">
|
||||
<el-tag :type="currentSourceEnabled ? 'success' : 'info'" effect="plain">
|
||||
{{ currentSourceEnabled ? '已启用' : '已停用' }}
|
||||
</el-tag>
|
||||
<el-switch
|
||||
:model-value="currentSourceEnabled"
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
@change="
|
||||
(value) => {
|
||||
if (currentSource) currentSource.enabled = Boolean(value)
|
||||
}
|
||||
"
|
||||
/>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
@click="removeSource(selectedSourceKey)"
|
||||
>
|
||||
删除账号
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-panel">
|
||||
<el-form label-width="auto" label-position="top" class="editor-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="账号名称">
|
||||
<el-input
|
||||
:model-value="currentSource?.label || ''"
|
||||
placeholder="例如:生产履约号 / 夜班履约号 / 华东履约号"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
if (currentSource) currentSource.label = String(value)
|
||||
}
|
||||
"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="系统 ID">
|
||||
<el-input :model-value="currentSource?.key || ''" readonly />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="登录账号">
|
||||
<el-input
|
||||
v-model="cloudtentaclesForm.username"
|
||||
placeholder="cloudtentacles 登录账号"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="cloudtentaclesForm.phone" placeholder="接收短信验证码的手机号" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="登录密码">
|
||||
<el-input
|
||||
v-model="cloudtentaclesForm.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="cloudtentacles 登录密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Base URL">
|
||||
<el-input
|
||||
v-model="cloudtentaclesForm.baseUrl"
|
||||
placeholder="https://123.207.217.176"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="session-panel">
|
||||
<div class="session-card-head">
|
||||
<div>
|
||||
<strong>登录与会话</strong>
|
||||
<p>发送验证码后测试登录;已有 token 可直接校验。</p>
|
||||
</div>
|
||||
<div class="session-card-actions">
|
||||
<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>
|
||||
|
||||
<el-form label-position="top" class="session-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="短信验证码">
|
||||
<el-input v-model="cloudtentaclesForm.code" placeholder="收到验证码后填写" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手动 token">
|
||||
<el-input
|
||||
v-model="cloudtentaclesForm.token"
|
||||
placeholder="仅在需要手动校验时填写"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-collapse
|
||||
:model-value="advancedCollapseNames"
|
||||
class="advanced-collapse"
|
||||
@update:model-value="handleAdvancedCollapseChange"
|
||||
@change="handleAdvancedCollapseChange"
|
||||
>
|
||||
<el-collapse-item name="advanced" title="高级参数与调试字段">
|
||||
<el-form label-width="auto" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="deviceId">
|
||||
<el-input
|
||||
v-model="cloudtentaclesForm.deviceId"
|
||||
placeholder="默认 08bc9d8c-fd15-48ea-bc00-8d754076cafc"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="deviceType">
|
||||
<el-input-number
|
||||
v-model="cloudtentaclesForm.deviceType"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="debug-field-grid">
|
||||
<el-form-item label="VN Key">
|
||||
<el-input v-model="cloudtentaclesForm.vnKey" placeholder="例如 1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="VN ID">
|
||||
<el-input-number
|
||||
v-model="cloudtentaclesForm.vnId"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="SKU ID">
|
||||
<el-input-number
|
||||
v-model="cloudtentaclesForm.skuId"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="购买数量">
|
||||
<el-input-number
|
||||
v-model="cloudtentaclesForm.skuCount"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="cloudtentaclesResultError"
|
||||
:title="cloudtentaclesResultError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<div class="status-strip mt-4">
|
||||
<div class="status-step" :class="{ 'status-step--done': cloudtentaclesSmsResult }">
|
||||
<span>短信</span>
|
||||
<strong>{{ cloudtentaclesSmsResult ? '已发送' : '未执行' }}</strong>
|
||||
<em>
|
||||
{{
|
||||
cloudtentaclesSmsResult
|
||||
? `${cloudtentaclesSmsResult.phoneMasked} · ${formatAdminDateTime(cloudtentaclesSmsResult.sentAt)}`
|
||||
: '先确认手机号可用'
|
||||
}}
|
||||
</em>
|
||||
</div>
|
||||
<div class="status-step" :class="{ 'status-step--done': cloudtentaclesLoginResult }">
|
||||
<span>登录</span>
|
||||
<strong>{{ cloudtentaclesLoginResult ? '成功' : '未执行' }}</strong>
|
||||
<em>
|
||||
{{
|
||||
cloudtentaclesLoginResult
|
||||
? `${cloudtentaclesLoginResult.phoneMasked} · ${formatAdminDateTime(cloudtentaclesLoginResult.loggedInAt)}`
|
||||
: '填写验证码后测试登录'
|
||||
}}
|
||||
</em>
|
||||
</div>
|
||||
<div class="status-step" :class="{ 'status-step--done': cloudtentaclesValidateResult }">
|
||||
<span>会话</span>
|
||||
<strong>{{ cloudtentaclesValidateResult ? '已校验' : '未执行' }}</strong>
|
||||
<em>
|
||||
{{
|
||||
cloudtentaclesValidateResult
|
||||
? `权限 ${cloudtentaclesValidateResult.session.permissionCount} 项 · 余额 ${cloudtentaclesValidateResult.asset}`
|
||||
: '登录成功后校验 token'
|
||||
}}
|
||||
</em>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cloud-config-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.crud-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.account-column,
|
||||
.editor-column {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.account-column-head,
|
||||
.editor-head,
|
||||
.session-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.account-column-head p,
|
||||
.editor-head p,
|
||||
.session-card-head p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.account-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.account-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #f8fafc;
|
||||
padding: var(--space-3);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.account-item:hover {
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.account-item--active {
|
||||
border-color: var(--el-color-primary);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.account-item-head,
|
||||
.account-item-meta,
|
||||
.account-item-foot,
|
||||
.editor-head-actions,
|
||||
.session-card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-item-head {
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-item-head strong,
|
||||
.account-item-meta span,
|
||||
.account-item-foot span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-item-meta,
|
||||
.account-item-foot {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.account-item-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.account-item-foot {
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.editor-head > div:first-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-head > div:first-child strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px var(--space-4);
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid rgba(59, 130, 246, 0.14);
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.72), rgba(255, 255, 255, 0.92));
|
||||
}
|
||||
|
||||
.session-card-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.session-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.advanced-collapse {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.advanced-collapse :deep(.el-collapse-item__header) {
|
||||
height: 38px;
|
||||
background: transparent;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.advanced-collapse :deep(.el-collapse-item__wrap) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.debug-field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 2px var(--space-3);
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.status-step {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.status-step span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-step strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.status-step em {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-step--done {
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
background: var(--color-success-light);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.crud-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.config-header,
|
||||
.editor-head,
|
||||
.session-card-head {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-grid,
|
||||
.debug-field-grid,
|
||||
.status-strip,
|
||||
.account-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-267
@@ -1,267 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useRememberedCollapse } from '../../composables/useRememberedCollapse'
|
||||
|
||||
type Props = {
|
||||
cloudtentaclesDebugLoading: boolean
|
||||
handleCloudtentaclesFetchAsset: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchCategories: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchSkuList: () => void | Promise<void>
|
||||
handleCloudtentaclesBuySku: () => void | Promise<void>
|
||||
handleCloudtentaclesUseSku: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchKnapsack: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchVnList: () => void | Promise<void>
|
||||
handleCloudtentaclesAppointVn: () => void | Promise<void>
|
||||
handleCloudtentaclesGenerateVnLoginCode: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchVnCode: () => void | Promise<void>
|
||||
handleCloudtentaclesVerifyVnCode: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchBindUrl: () => void | Promise<void>
|
||||
handleCloudtentaclesBackVn: () => void | Promise<void>
|
||||
handleCloudtentaclesRunFullFlow: () => void | Promise<void>
|
||||
cloudtentaclesDebugResult: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const {
|
||||
activeNames: debugCollapseNames,
|
||||
handleChange: handleDebugCollapseChange,
|
||||
} = useRememberedCollapse('admin:platform-config:cloudtentacles:debug:collapse')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="debug-card">
|
||||
<el-collapse
|
||||
:model-value="debugCollapseNames"
|
||||
@update:model-value="handleDebugCollapseChange"
|
||||
@change="handleDebugCollapseChange"
|
||||
>
|
||||
<el-collapse-item name="debug">
|
||||
<template #title>
|
||||
<div class="debug-title">
|
||||
<strong>开发调试工具</strong>
|
||||
<span>分类、SKU、虚拟号和完整流程调试</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="debug-panel">
|
||||
<div class="debug-group">
|
||||
<span>基础</span>
|
||||
<div class="debug-buttons">
|
||||
<el-button
|
||||
:loading="cloudtentaclesDebugLoading"
|
||||
@click="handleCloudtentaclesFetchAsset"
|
||||
>
|
||||
余额
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="cloudtentaclesDebugLoading"
|
||||
@click="handleCloudtentaclesFetchCategories"
|
||||
>
|
||||
分类
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="cloudtentaclesDebugLoading"
|
||||
@click="handleCloudtentaclesFetchSkuList"
|
||||
>
|
||||
SKU
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="cloudtentaclesDebugLoading"
|
||||
@click="handleCloudtentaclesFetchKnapsack"
|
||||
>
|
||||
背包
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug-group">
|
||||
<span>履约</span>
|
||||
<div class="debug-buttons">
|
||||
<el-button :loading="cloudtentaclesDebugLoading" @click="handleCloudtentaclesBuySku">
|
||||
购买
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="cloudtentaclesDebugLoading"
|
||||
type="primary"
|
||||
@click="handleCloudtentaclesUseSku"
|
||||
>
|
||||
发货
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="cloudtentaclesDebugLoading"
|
||||
type="success"
|
||||
@click="handleCloudtentaclesRunFullFlow"
|
||||
>
|
||||
完整流程
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug-group">
|
||||
<span>虚拟号</span>
|
||||
<div class="debug-buttons">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre v-if="cloudtentaclesDebugResult" class="debug-pre mt-4">{{
|
||||
cloudtentaclesDebugResult
|
||||
}}</pre>
|
||||
<div v-else class="debug-empty">执行调试动作后显示原始返回数据。</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.debug-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.debug-card :deep(.el-card__body) {
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.debug-card :deep(.el-collapse) {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.debug-card :deep(.el-collapse-item__header) {
|
||||
height: 54px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.debug-card :deep(.el-collapse-item__wrap) {
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.debug-title {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.debug-title strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
.debug-title span {
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.debug-group {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.debug-group > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.debug-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.debug-buttons :deep(.el-button) {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.debug-pre {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.debug-empty {
|
||||
margin-bottom: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.debug-title {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.debug-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AdminCloudtentaclesPersistedSession,
|
||||
AdminCloudtentaclesSessionsMap,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
} from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type Props = {
|
||||
cloudtentaclesFilePath: string
|
||||
cloudtentaclesSessionFilePath: string
|
||||
cloudtentaclesPersistedSession: AdminCloudtentaclesPersistedSession | null
|
||||
sources: AdminCloudtentaclesSourceItem[]
|
||||
sessions: AdminCloudtentaclesSessionsMap
|
||||
selectedSourceKey: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
function resolveOrphanSessionEntries(
|
||||
sources: AdminCloudtentaclesSourceItem[],
|
||||
sessions: AdminCloudtentaclesSessionsMap,
|
||||
) {
|
||||
return Object.entries(sessions).filter(([key]) => !sources.some((source) => source.key === key))
|
||||
}
|
||||
|
||||
function countSavedTokens(sessions: AdminCloudtentaclesSessionsMap) {
|
||||
return Object.values(sessions).filter((session) => session?.hasToken).length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="cloudtentacles-overview">
|
||||
<div class="overview-copy">
|
||||
<span class="eyebrow">履约账号</span>
|
||||
<strong>cloudtentacles</strong>
|
||||
<p>管理外部履约账号、短信登录和 token 会话。订单来源同步不在这里处理。</p>
|
||||
</div>
|
||||
|
||||
<div class="overview-metrics">
|
||||
<div class="metric-item">
|
||||
<span>账号</span>
|
||||
<strong>{{ sources.length }}</strong>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span>已保存 token</span>
|
||||
<strong>{{ countSavedTokens(sessions) }}</strong>
|
||||
</div>
|
||||
<div class="metric-item metric-item--wide">
|
||||
<span>当前账号</span>
|
||||
<strong>{{ selectedSourceKey || '-' }}</strong>
|
||||
</div>
|
||||
<div class="metric-item metric-item--wide">
|
||||
<span>当前 token</span>
|
||||
<strong>
|
||||
{{
|
||||
cloudtentaclesPersistedSession?.hasToken
|
||||
? cloudtentaclesPersistedSession.tokenMasked
|
||||
: '未保存'
|
||||
}}
|
||||
</strong>
|
||||
<small v-if="cloudtentaclesPersistedSession?.hasToken">
|
||||
{{ formatAdminDateTime(cloudtentaclesPersistedSession.loggedInAt) }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-strip">
|
||||
<span>配置 {{ cloudtentaclesFilePath || '-' }}</span>
|
||||
<span>会话 {{ cloudtentaclesSessionFilePath || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="sources.length > 0 || Object.keys(sessions).length > 0" class="session-strip">
|
||||
<span v-for="source in sources" :key="source.key" class="session-chip">
|
||||
<strong>{{ source.label || source.key }}</strong>
|
||||
<em>{{ sessions[source.key]?.hasToken ? sessions[source.key]?.tokenMasked : '未登录' }}</em>
|
||||
</span>
|
||||
<span
|
||||
v-for="[key, session] in resolveOrphanSessionEntries(sources, sessions)"
|
||||
:key="`orphan-${key}`"
|
||||
class="session-chip session-chip--orphan"
|
||||
>
|
||||
<strong>{{ key }}</strong>
|
||||
<em>{{ session.hasToken ? session.tokenMasked : '孤立会话' }}</em>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cloudtentacles-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(420px, 1.5fr);
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.overview-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.overview-copy strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-xl);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.overview-copy p {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.overview-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 128px minmax(120px, 1fr) minmax(180px, 1.2fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
align-content: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.metric-item span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-item strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-item small {
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-strip,
|
||||
.session-strip {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.file-strip span {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 5px 9px;
|
||||
border-radius: var(--radius-full);
|
||||
background: #eef3f8;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.session-strip {
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.session-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
padding: 4px 9px;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success-dark);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.session-chip strong,
|
||||
.session-chip em {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-chip em {
|
||||
color: inherit;
|
||||
font-style: normal;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.session-chip--orphan {
|
||||
border-color: rgba(245, 158, 11, 0.24);
|
||||
background: var(--color-warning-light);
|
||||
color: var(--color-warning-dark);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.cloudtentacles-overview,
|
||||
.overview-metrics {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.cloudtentacles-overview,
|
||||
.overview-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AdminCloudtentaclesLoginTestResult,
|
||||
AdminCloudtentaclesValidateSessionResult,
|
||||
} from '@/types/admin'
|
||||
import AdminResultCard from '@/components/admin/AdminResultCard.vue'
|
||||
|
||||
type Props = {
|
||||
cloudtentaclesLoginResult: AdminCloudtentaclesLoginTestResult | null
|
||||
cloudtentaclesValidateResult: AdminCloudtentaclesValidateSessionResult | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card v-if="cloudtentaclesLoginResult || cloudtentaclesValidateResult" class="section-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<span class="card-title">cloudtentacles 调试结果</span>
|
||||
<p class="card-desc">
|
||||
用于确认当前账号会话、权限和用户信息,后续发货执行器会复用这里的登录能力。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="result-grid">
|
||||
<AdminResultCard
|
||||
label="当前 token"
|
||||
:value="
|
||||
cloudtentaclesValidateResult?.session.tokenMasked ||
|
||||
cloudtentaclesLoginResult?.session.tokenMasked ||
|
||||
'-'
|
||||
"
|
||||
>
|
||||
<p>原始 token 已写入上方输入框,可继续用于校验或后续调试。</p>
|
||||
</AdminResultCard>
|
||||
<AdminResultCard
|
||||
label="权限"
|
||||
:value="`${cloudtentaclesValidateResult?.session.permissionCount || cloudtentaclesLoginResult?.session.permissionCount || 0} 项`"
|
||||
>
|
||||
<p>
|
||||
{{
|
||||
(
|
||||
cloudtentaclesValidateResult?.session.permissions ||
|
||||
cloudtentaclesLoginResult?.session.permissions ||
|
||||
[]
|
||||
).join('、') || '暂无'
|
||||
}}
|
||||
</p>
|
||||
</AdminResultCard>
|
||||
<AdminResultCard
|
||||
label="余额"
|
||||
:value="cloudtentaclesValidateResult?.asset || cloudtentaclesLoginResult?.asset || 0"
|
||||
>
|
||||
<p>可用于确认当前履约账号状态。</p>
|
||||
</AdminResultCard>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
</style>
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AdminCloudtentaclesLoginTestResult,
|
||||
AdminCloudtentaclesPersistedSession,
|
||||
AdminCloudtentaclesSendSmsResult,
|
||||
AdminCloudtentaclesSessionsMap,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
AdminCloudtentaclesValidateSessionResult,
|
||||
} from '@/types/admin'
|
||||
import AdminCloudtentaclesInfoCard from './AdminCloudtentaclesInfoCard.vue'
|
||||
import AdminCloudtentaclesConfigCard from './AdminCloudtentaclesConfigCard.vue'
|
||||
import AdminCloudtentaclesDebugCard from './AdminCloudtentaclesDebugCard.vue'
|
||||
import AdminCloudtentaclesResultCard from './AdminCloudtentaclesResultCard.vue'
|
||||
|
||||
type CloudtentaclesForm = {
|
||||
baseUrl: string
|
||||
username: string
|
||||
password: string
|
||||
phone: string
|
||||
code: string
|
||||
token: string
|
||||
vnKey: string
|
||||
vnId: number
|
||||
skuId: number
|
||||
skuCount: number
|
||||
deviceId: string
|
||||
deviceType: number
|
||||
}
|
||||
|
||||
type CloudtentaclesStats = {
|
||||
hasCredential: boolean
|
||||
smsSent: boolean
|
||||
validated: boolean
|
||||
sourceCount: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
cloudtentaclesFilePath: string
|
||||
cloudtentaclesSessionFilePath: string
|
||||
cloudtentaclesForm: CloudtentaclesForm
|
||||
cloudtentaclesSaving: boolean
|
||||
cloudtentaclesSendingSms: boolean
|
||||
cloudtentaclesTesting: boolean
|
||||
cloudtentaclesValidating: boolean
|
||||
cloudtentaclesResultError: string
|
||||
cloudtentaclesSmsResult: AdminCloudtentaclesSendSmsResult | null
|
||||
cloudtentaclesLoginResult: AdminCloudtentaclesLoginTestResult | null
|
||||
cloudtentaclesValidateResult: AdminCloudtentaclesValidateSessionResult | null
|
||||
cloudtentaclesDebugLoading: boolean
|
||||
cloudtentaclesDebugResult: string
|
||||
cloudtentaclesPersistedSession: AdminCloudtentaclesPersistedSession | null
|
||||
cloudtentaclesStats: CloudtentaclesStats
|
||||
sources: AdminCloudtentaclesSourceItem[]
|
||||
sessions: AdminCloudtentaclesSessionsMap
|
||||
selectedSourceKey: string
|
||||
currentSourceEnabled: boolean
|
||||
currentSource: AdminCloudtentaclesSourceItem | null
|
||||
currentSession: AdminCloudtentaclesPersistedSession | null
|
||||
handleCloudtentaclesSaveSource: () => void | Promise<void>
|
||||
handleCloudtentaclesSendSmsCode: () => void | Promise<void>
|
||||
handleCloudtentaclesTestLogin: () => void | Promise<void>
|
||||
handleCloudtentaclesValidateSession: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchAsset: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchCategories: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchSkuList: () => void | Promise<void>
|
||||
handleCloudtentaclesBuySku: () => void | Promise<void>
|
||||
handleCloudtentaclesUseSku: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchKnapsack: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchVnList: () => void | Promise<void>
|
||||
handleCloudtentaclesAppointVn: () => void | Promise<void>
|
||||
handleCloudtentaclesGenerateVnLoginCode: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchVnCode: () => void | Promise<void>
|
||||
handleCloudtentaclesVerifyVnCode: () => void | Promise<void>
|
||||
handleCloudtentaclesFetchBindUrl: () => void | Promise<void>
|
||||
handleCloudtentaclesBackVn: () => void | Promise<void>
|
||||
handleCloudtentaclesRunFullFlow: () => void | Promise<void>
|
||||
switchSource: (sourceKey: string) => void
|
||||
addSource: () => void
|
||||
removeSource: (sourceKey: string) => void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="platform-section">
|
||||
<AdminCloudtentaclesInfoCard
|
||||
:cloudtentacles-file-path="cloudtentaclesFilePath"
|
||||
:cloudtentacles-session-file-path="cloudtentaclesSessionFilePath"
|
||||
:cloudtentacles-persisted-session="cloudtentaclesPersistedSession"
|
||||
:sources="sources"
|
||||
:sessions="sessions"
|
||||
:selected-source-key="selectedSourceKey"
|
||||
/>
|
||||
|
||||
<AdminCloudtentaclesConfigCard
|
||||
:cloudtentacles-form="cloudtentaclesForm"
|
||||
:cloudtentacles-saving="cloudtentaclesSaving"
|
||||
:cloudtentacles-sending-sms="cloudtentaclesSendingSms"
|
||||
:cloudtentacles-testing="cloudtentaclesTesting"
|
||||
:cloudtentacles-validating="cloudtentaclesValidating"
|
||||
:cloudtentacles-result-error="cloudtentaclesResultError"
|
||||
:cloudtentacles-sms-result="cloudtentaclesSmsResult"
|
||||
:cloudtentacles-login-result="cloudtentaclesLoginResult"
|
||||
:cloudtentacles-validate-result="cloudtentaclesValidateResult"
|
||||
:selected-source-key="selectedSourceKey"
|
||||
:current-source-enabled="currentSourceEnabled"
|
||||
:sources="sources"
|
||||
:sessions="sessions"
|
||||
:current-source="currentSource"
|
||||
:current-session="currentSession"
|
||||
:handle-cloudtentacles-save-source="handleCloudtentaclesSaveSource"
|
||||
:handle-cloudtentacles-send-sms-code="handleCloudtentaclesSendSmsCode"
|
||||
:handle-cloudtentacles-test-login="handleCloudtentaclesTestLogin"
|
||||
:handle-cloudtentacles-validate-session="handleCloudtentaclesValidateSession"
|
||||
:switch-source="switchSource"
|
||||
:add-source="addSource"
|
||||
:remove-source="removeSource"
|
||||
/>
|
||||
|
||||
<AdminCloudtentaclesDebugCard
|
||||
:cloudtentacles-debug-loading="cloudtentaclesDebugLoading"
|
||||
:cloudtentacles-debug-result="cloudtentaclesDebugResult"
|
||||
:handle-cloudtentacles-fetch-asset="handleCloudtentaclesFetchAsset"
|
||||
:handle-cloudtentacles-fetch-categories="handleCloudtentaclesFetchCategories"
|
||||
:handle-cloudtentacles-fetch-sku-list="handleCloudtentaclesFetchSkuList"
|
||||
:handle-cloudtentacles-buy-sku="handleCloudtentaclesBuySku"
|
||||
:handle-cloudtentacles-use-sku="handleCloudtentaclesUseSku"
|
||||
:handle-cloudtentacles-fetch-knapsack="handleCloudtentaclesFetchKnapsack"
|
||||
:handle-cloudtentacles-fetch-vn-list="handleCloudtentaclesFetchVnList"
|
||||
:handle-cloudtentacles-appoint-vn="handleCloudtentaclesAppointVn"
|
||||
:handle-cloudtentacles-generate-vn-login-code="handleCloudtentaclesGenerateVnLoginCode"
|
||||
:handle-cloudtentacles-fetch-vn-code="handleCloudtentaclesFetchVnCode"
|
||||
:handle-cloudtentacles-verify-vn-code="handleCloudtentaclesVerifyVnCode"
|
||||
:handle-cloudtentacles-fetch-bind-url="handleCloudtentaclesFetchBindUrl"
|
||||
:handle-cloudtentacles-back-vn="handleCloudtentaclesBackVn"
|
||||
:handle-cloudtentacles-run-full-flow="handleCloudtentaclesRunFullFlow"
|
||||
/>
|
||||
|
||||
<AdminCloudtentaclesResultCard
|
||||
:cloudtentacles-login-result="cloudtentaclesLoginResult"
|
||||
:cloudtentacles-validate-result="cloudtentaclesValidateResult"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
-276
@@ -1,276 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
AdminKuaishouEticketDetailResult,
|
||||
} from '@/types/admin'
|
||||
|
||||
type KuaishouEticketDebugForm = {
|
||||
selectedShopId: string
|
||||
queryTicketCode: string
|
||||
eTicketId: string
|
||||
oid: string
|
||||
formToken: string
|
||||
num: number
|
||||
storeId: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
kuaishouEticketShops: { id: string; kshopName: string; shopId: string }[]
|
||||
kuaishouEticketForm: KuaishouEticketDebugForm
|
||||
kuaishouEticketDetailing: boolean
|
||||
kuaishouEticketConsuming: boolean
|
||||
kuaishouEticketDetailResult: AdminKuaishouEticketDetailResult | null
|
||||
kuaishouEticketConsumeResult: AdminKuaishouEticketConsumeResult | null
|
||||
handleKuaishouEticketQueryDetail: () => void | Promise<void>
|
||||
handleKuaishouEticketConsume: () => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
function formatDetailStatus(result: AdminKuaishouEticketDetailResult | null) {
|
||||
if (!result) return '未查询'
|
||||
if (result.ok) return result.alreadyConsumed ? '已核销' : '可核销'
|
||||
return '查询异常'
|
||||
}
|
||||
|
||||
function formatConsumeStatus(result: AdminKuaishouEticketConsumeResult | null) {
|
||||
if (!result) return '未执行'
|
||||
if (result.consumed) return '核销完成'
|
||||
if (result.alreadyConsumed) return '已核销'
|
||||
return result.ok ? '已返回' : '执行异常'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="section-card debug-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">核销调试</span>
|
||||
<p class="card-desc">查询券码后自动回填 eTicketId、oid、formToken,再执行核销。</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button
|
||||
:loading="kuaishouEticketDetailing"
|
||||
@click="handleKuaishouEticketQueryDetail"
|
||||
>
|
||||
查询核销信息
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="kuaishouEticketConsuming"
|
||||
type="primary"
|
||||
@click="handleKuaishouEticketConsume"
|
||||
>
|
||||
执行核销
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="debug-grid">
|
||||
<section class="query-panel">
|
||||
<el-form label-position="top">
|
||||
<div class="query-grid">
|
||||
<el-form-item label="调试店铺">
|
||||
<el-select
|
||||
v-model="kuaishouEticketForm.selectedShopId"
|
||||
placeholder="请选择店铺"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="shop in kuaishouEticketShops"
|
||||
:key="shop.id"
|
||||
:label="shop.kshopName || shop.shopId || '未命名店铺'"
|
||||
:value="shop.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="查询券码">
|
||||
<el-input
|
||||
v-model="kuaishouEticketForm.queryTicketCode"
|
||||
placeholder="用于查询核销信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="核销数量">
|
||||
<el-input-number
|
||||
v-model="kuaishouEticketForm.num"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="storeId">
|
||||
<el-input v-model="kuaishouEticketForm.storeId" placeholder="默认 0" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="auto-fill-row">
|
||||
<div class="auto-fill-item">
|
||||
<span>eTicketId</span>
|
||||
<strong>{{ kuaishouEticketForm.eTicketId || '查询后回填' }}</strong>
|
||||
</div>
|
||||
<div class="auto-fill-item">
|
||||
<span>oid</span>
|
||||
<strong>{{ kuaishouEticketForm.oid || '查询后回填' }}</strong>
|
||||
</div>
|
||||
<div class="auto-fill-item">
|
||||
<span>formToken</span>
|
||||
<strong>{{ kuaishouEticketForm.formToken || '查询后回填' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="result-panel">
|
||||
<div class="status-card">
|
||||
<span>详情查询</span>
|
||||
<strong>{{ formatDetailStatus(kuaishouEticketDetailResult) }}</strong>
|
||||
<p v-if="kuaishouEticketDetailResult?.detail">
|
||||
{{
|
||||
kuaishouEticketDetailResult.goods?.itemTitle ||
|
||||
kuaishouEticketDetailResult.goods?.skuDesc ||
|
||||
'商品信息未返回'
|
||||
}}
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ kuaishouEticketDetailResult?.errorMessage || '先输入券码并查询详情。' }}
|
||||
</p>
|
||||
<div v-if="kuaishouEticketDetailResult?.detail" class="count-row">
|
||||
<span>剩余 {{ kuaishouEticketDetailResult.detail.leftCount }}</span>
|
||||
<span>总数 {{ kuaishouEticketDetailResult.detail.totalCount }}</span>
|
||||
<span>状态 {{ kuaishouEticketDetailResult.detail.status || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-card">
|
||||
<span>核销执行</span>
|
||||
<strong>{{ formatConsumeStatus(kuaishouEticketConsumeResult) }}</strong>
|
||||
<p>
|
||||
{{
|
||||
kuaishouEticketConsumeResult?.errorMessage ||
|
||||
(kuaishouEticketConsumeResult?.ok ? '接口已返回结果。' : '查询成功后再执行核销。')
|
||||
}}
|
||||
</p>
|
||||
<div v-if="kuaishouEticketConsumeResult" class="count-row">
|
||||
<span>数量 {{ kuaishouEticketConsumeResult.num }}</span>
|
||||
<span>requestId {{ kuaishouEticketConsumeResult.requestId || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.debug-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.8fr);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.query-panel,
|
||||
.result-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.query-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1.6fr) minmax(120px, 0.8fr) minmax(
|
||||
120px,
|
||||
0.8fr
|
||||
);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.auto-fill-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.auto-fill-item,
|
||||
.status-card {
|
||||
min-width: 0;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.auto-fill-item {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.auto-fill-item span,
|
||||
.status-card > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.auto-fill-item strong,
|
||||
.status-card strong {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.status-card p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.count-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.count-row span {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.debug-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.query-grid,
|
||||
.auto-fill-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type Props = {
|
||||
kuaishouEticketFilePath: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="info-strip">
|
||||
<div class="info-main">
|
||||
<span class="card-title">快手小店核销</span>
|
||||
<p class="card-desc">
|
||||
独立维护店铺 Cookie,选择目标店铺后查询券码并执行核销。
|
||||
</p>
|
||||
</div>
|
||||
<div class="info-meta">
|
||||
<span>配置文件</span>
|
||||
<strong>{{ kuaishouEticketFilePath || '-' }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.info-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-main .card-desc {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.info-meta {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 260px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.info-meta strong {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.info-strip {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.info-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AdminKuaishouEticketDetailResult,
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
} from '@/types/admin'
|
||||
|
||||
type Props = {
|
||||
kuaishouEticketDetailResult: AdminKuaishouEticketDetailResult | null
|
||||
kuaishouEticketConsumeResult: AdminKuaishouEticketConsumeResult | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 详情结果 -->
|
||||
<el-card v-if="kuaishouEticketDetailResult" class="section-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<span class="card-title">核销详情返回</span>
|
||||
<p class="card-desc">成功查询后会自动回填 eTicketId、oid、formToken 和建议核销数量。</p>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="debug-pre">{{ JSON.stringify(kuaishouEticketDetailResult.raw, null, 2) }}</pre>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="kuaishouEticketConsumeResult" class="section-card">
|
||||
<template #header>
|
||||
<div>
|
||||
<span class="card-title">核销执行返回</span>
|
||||
<p class="card-desc">这里保留接口原始响应,方便核对 result、error_msg 和 requestId。</p>
|
||||
</div>
|
||||
</template>
|
||||
<pre class="debug-pre">{{ JSON.stringify(kuaishouEticketConsumeResult.raw, null, 2) }}</pre>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.debug-pre {
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
-470
@@ -1,470 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditableKuaishouEticketShop } from '../../composables/types'
|
||||
import type { AdminKuaishouEticketShopInfoResult } from '@/types/admin'
|
||||
|
||||
type KuaishouEticketStats = {
|
||||
configuredShopCount: number
|
||||
cookieReadyCount: number
|
||||
selectedShopReady: boolean
|
||||
detailReady: boolean
|
||||
consumed: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
kuaishouEticketShops: EditableKuaishouEticketShop[]
|
||||
kuaishouEticketForm: {
|
||||
baseUrl: string
|
||||
selectedShopId: string
|
||||
}
|
||||
kuaishouEticketSaving: boolean
|
||||
kuaishouEticketResolvingShopId: string
|
||||
kuaishouEticketResultError: string
|
||||
kuaishouEticketShopInfoResult: AdminKuaishouEticketShopInfoResult | null
|
||||
activeKuaishouEticketShop: EditableKuaishouEticketShop | null
|
||||
kuaishouEticketStats: KuaishouEticketStats
|
||||
addKuaishouEticketShop: () => void
|
||||
removeKuaishouEticketShop: (id: string) => void
|
||||
setKuaishouEticketDebugShop: (id: string) => void
|
||||
describeKuaishouEticketShop: (shop: EditableKuaishouEticketShop) => string
|
||||
getKuaishouEticketShopInitial: (shop: EditableKuaishouEticketShop) => string
|
||||
handleKuaishouEticketResolveShopInfo: (shop: EditableKuaishouEticketShop) => void | Promise<void>
|
||||
handleKuaishouEticketSaveSource: () => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="section-card shop-workspace">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">店铺配置</span>
|
||||
<p class="card-desc">维护多店铺 Cookie,当前选中店铺会直接用于下方查询与核销。</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button @click="addKuaishouEticketShop">新增店铺</el-button>
|
||||
<el-button
|
||||
:loading="kuaishouEticketSaving"
|
||||
type="primary"
|
||||
@click="handleKuaishouEticketSaveSource"
|
||||
>
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="workspace-grid">
|
||||
<aside class="shop-sidebar">
|
||||
<div class="sidebar-head">
|
||||
<div>
|
||||
<strong>{{ kuaishouEticketStats.configuredShopCount }} 家店铺</strong>
|
||||
<p>Cookie 就绪 {{ kuaishouEticketStats.cookieReadyCount }} 家</p>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="kuaishouEticketStats.selectedShopReady ? 'success' : 'warning'"
|
||||
effect="plain"
|
||||
>
|
||||
{{ kuaishouEticketStats.selectedShopReady ? '可调试' : '待补全' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div v-if="kuaishouEticketShops.length > 0" class="shop-list">
|
||||
<button
|
||||
v-for="shop in kuaishouEticketShops"
|
||||
:key="shop.id"
|
||||
type="button"
|
||||
class="shop-item"
|
||||
:class="{ 'shop-item--active': kuaishouEticketForm.selectedShopId === shop.id }"
|
||||
@click="setKuaishouEticketDebugShop(shop.id)"
|
||||
>
|
||||
<div class="shop-avatar">
|
||||
<img
|
||||
v-if="shop.userAvatar"
|
||||
:src="shop.userAvatar"
|
||||
:alt="shop.kshopName || shop.shopId || '店铺头像'"
|
||||
/>
|
||||
<span v-else>{{ getKuaishouEticketShopInitial(shop) }}</span>
|
||||
</div>
|
||||
<div class="shop-summary">
|
||||
<div class="shop-name">
|
||||
<strong>{{ shop.kshopName || shop.shopId || '未命名店铺' }}</strong>
|
||||
<el-tag :type="shop.enabled ? 'success' : 'info'" size="small">
|
||||
{{ shop.enabled ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p>{{ shop.shopId || '等待识别 shopId' }}</p>
|
||||
<div class="shop-flags">
|
||||
<span :class="{ 'is-ok': shop.cookie.trim() }">
|
||||
{{ shop.cookie.trim() ? 'Cookie 已填' : '缺 Cookie' }}
|
||||
</span>
|
||||
<span v-if="kuaishouEticketForm.selectedShopId === shop.id">当前调试</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="compact-empty">
|
||||
<strong>还没有店铺</strong>
|
||||
<p>新增店铺后粘贴 Cookie,再读取店铺信息确认归属。</p>
|
||||
<el-button type="primary" plain @click="addKuaishouEticketShop">新增店铺</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="shop-editor">
|
||||
<el-form label-position="top" class="editor-form">
|
||||
<div class="base-row">
|
||||
<el-form-item label="Base URL">
|
||||
<el-input
|
||||
v-model="kuaishouEticketForm.baseUrl"
|
||||
placeholder="https://s.kwaixiaodian.com"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<template v-if="activeKuaishouEticketShop">
|
||||
<div class="editor-head">
|
||||
<div>
|
||||
<strong>
|
||||
{{
|
||||
activeKuaishouEticketShop.kshopName ||
|
||||
activeKuaishouEticketShop.shopId ||
|
||||
'未命名店铺'
|
||||
}}
|
||||
</strong>
|
||||
<p>{{ describeKuaishouEticketShop(activeKuaishouEticketShop) }}</p>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<el-switch
|
||||
v-model="activeKuaishouEticketShop.enabled"
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
/>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
size="small"
|
||||
@click="removeKuaishouEticketShop(activeKuaishouEticketShop.id)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<el-form-item label="kshopName">
|
||||
<el-input
|
||||
v-model="activeKuaishouEticketShop.kshopName"
|
||||
placeholder="例如:稚嫩游戏交易店"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="店铺 ID">
|
||||
<el-input
|
||||
v-model="activeKuaishouEticketShop.shopId"
|
||||
readonly
|
||||
placeholder="通过 info 接口自动识别"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="Cookie">
|
||||
<el-input
|
||||
v-model="activeKuaishouEticketShop.cookie"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="直接粘贴该店铺请求头中的完整 Cookie"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="editor-footer">
|
||||
<div class="recognize-result">
|
||||
<span>最近识别</span>
|
||||
<strong>
|
||||
{{
|
||||
kuaishouEticketShopInfoResult?.shop.kshopName ||
|
||||
kuaishouEticketShopInfoResult?.shop.shopId ||
|
||||
'未执行'
|
||||
}}
|
||||
</strong>
|
||||
<small v-if="kuaishouEticketShopInfoResult">
|
||||
结算状态 {{ kuaishouEticketShopInfoResult.shop.settleStatus }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<el-button
|
||||
:loading="kuaishouEticketResolvingShopId === activeKuaishouEticketShop.id"
|
||||
@click="handleKuaishouEticketResolveShopInfo(activeKuaishouEticketShop)"
|
||||
>
|
||||
读取店铺信息
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
@click="setKuaishouEticketDebugShop(activeKuaishouEticketShop.id)"
|
||||
>
|
||||
设为调试店铺
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="editor-empty">
|
||||
<strong>请选择或新增店铺</strong>
|
||||
<p>左侧选择店铺后,这里会显示 Cookie、店铺 ID 和识别结果。</p>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="kuaishouEticketResultError"
|
||||
:title="kuaishouEticketResultError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.shop-workspace :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.shop-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border-right: 1px solid var(--border-default);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.sidebar-head,
|
||||
.editor-head,
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.sidebar-head strong,
|
||||
.editor-head strong,
|
||||
.compact-empty strong,
|
||||
.editor-empty strong,
|
||||
.recognize-result strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-head p,
|
||||
.editor-head p,
|
||||
.compact-empty p,
|
||||
.editor-empty p {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.shop-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.shop-item {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.shop-item:hover,
|
||||
.shop-item--active {
|
||||
border-color: var(--border-hover);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.shop-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--border-hover);
|
||||
border-radius: var(--radius-md);
|
||||
background: #eef6ff;
|
||||
color: var(--color-primary-dark);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.shop-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shop-summary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shop-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shop-name strong {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shop-summary p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shop-flags {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.shop-flags .is-ok {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.compact-empty,
|
||||
.editor-empty {
|
||||
display: grid;
|
||||
place-items: start;
|
||||
align-content: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 220px;
|
||||
padding: var(--space-4);
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.shop-editor {
|
||||
min-width: 0;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.editor-form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.base-row {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.editor-actions,
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.editor-footer {
|
||||
align-items: flex-end;
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.recognize-result {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.recognize-result small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shop-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.shop-editor {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-head,
|
||||
.editor-footer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditableKuaishouEticketShop } from '../../composables/types'
|
||||
import type {
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
AdminKuaishouEticketDetailResult,
|
||||
AdminKuaishouEticketShopInfoResult,
|
||||
} from '@/types/admin'
|
||||
import AdminKuaishouEticketInfoCard from './AdminKuaishouEticketInfoCard.vue'
|
||||
import AdminKuaishouEticketShopConfig from './AdminKuaishouEticketShopConfig.vue'
|
||||
import AdminKuaishouEticketDebug from './AdminKuaishouEticketDebug.vue'
|
||||
import AdminKuaishouEticketResults from './AdminKuaishouEticketResults.vue'
|
||||
|
||||
type KuaishouEticketStats = {
|
||||
configuredShopCount: number
|
||||
cookieReadyCount: number
|
||||
selectedShopReady: boolean
|
||||
detailReady: boolean
|
||||
consumed: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
kuaishouEticketFilePath: string
|
||||
kuaishouEticketShops: EditableKuaishouEticketShop[]
|
||||
kuaishouEticketForm: {
|
||||
baseUrl: string
|
||||
selectedShopId: string
|
||||
queryTicketCode: string
|
||||
eTicketId: string
|
||||
oid: string
|
||||
formToken: string
|
||||
num: number
|
||||
storeId: string
|
||||
}
|
||||
kuaishouEticketSaving: boolean
|
||||
kuaishouEticketResolvingShopId: string
|
||||
kuaishouEticketDetailing: boolean
|
||||
kuaishouEticketConsuming: boolean
|
||||
kuaishouEticketResultError: string
|
||||
kuaishouEticketShopInfoResult: AdminKuaishouEticketShopInfoResult | null
|
||||
kuaishouEticketDetailResult: AdminKuaishouEticketDetailResult | null
|
||||
kuaishouEticketConsumeResult: AdminKuaishouEticketConsumeResult | null
|
||||
activeKuaishouEticketShop: EditableKuaishouEticketShop | null
|
||||
kuaishouEticketStats: KuaishouEticketStats
|
||||
addKuaishouEticketShop: () => void
|
||||
removeKuaishouEticketShop: (id: string) => void
|
||||
setKuaishouEticketDebugShop: (id: string) => void
|
||||
describeKuaishouEticketShop: (shop: EditableKuaishouEticketShop) => string
|
||||
getKuaishouEticketShopInitial: (shop: EditableKuaishouEticketShop) => string
|
||||
handleKuaishouEticketResolveShopInfo: (shop: EditableKuaishouEticketShop) => void | Promise<void>
|
||||
handleKuaishouEticketSaveSource: () => void | Promise<void>
|
||||
handleKuaishouEticketQueryDetail: () => void | Promise<void>
|
||||
handleKuaishouEticketConsume: () => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="platform-section">
|
||||
<AdminKuaishouEticketInfoCard :kuaishou-eticket-file-path="kuaishouEticketFilePath" />
|
||||
|
||||
<AdminKuaishouEticketShopConfig
|
||||
:kuaishou-eticket-shops="kuaishouEticketShops"
|
||||
:kuaishou-eticket-form="kuaishouEticketForm"
|
||||
:kuaishou-eticket-saving="kuaishouEticketSaving"
|
||||
:kuaishou-eticket-resolving-shop-id="kuaishouEticketResolvingShopId"
|
||||
:kuaishou-eticket-result-error="kuaishouEticketResultError"
|
||||
:kuaishou-eticket-shop-info-result="kuaishouEticketShopInfoResult"
|
||||
:active-kuaishou-eticket-shop="activeKuaishouEticketShop"
|
||||
:kuaishou-eticket-stats="kuaishouEticketStats"
|
||||
:add-kuaishou-eticket-shop="addKuaishouEticketShop"
|
||||
:remove-kuaishou-eticket-shop="removeKuaishouEticketShop"
|
||||
:set-kuaishou-eticket-debug-shop="setKuaishouEticketDebugShop"
|
||||
:describe-kuaishou-eticket-shop="describeKuaishouEticketShop"
|
||||
:get-kuaishou-eticket-shop-initial="getKuaishouEticketShopInitial"
|
||||
:handle-kuaishou-eticket-resolve-shop-info="handleKuaishouEticketResolveShopInfo"
|
||||
:handle-kuaishou-eticket-save-source="handleKuaishouEticketSaveSource"
|
||||
/>
|
||||
|
||||
<AdminKuaishouEticketDebug
|
||||
:kuaishou-eticket-shops="kuaishouEticketShops"
|
||||
:kuaishou-eticket-form="kuaishouEticketForm"
|
||||
:kuaishou-eticket-detailing="kuaishouEticketDetailing"
|
||||
:kuaishou-eticket-consuming="kuaishouEticketConsuming"
|
||||
:kuaishou-eticket-detail-result="kuaishouEticketDetailResult"
|
||||
:kuaishou-eticket-consume-result="kuaishouEticketConsumeResult"
|
||||
:handle-kuaishou-eticket-query-detail="handleKuaishouEticketQueryDetail"
|
||||
:handle-kuaishou-eticket-consume="handleKuaishouEticketConsume"
|
||||
/>
|
||||
|
||||
<AdminKuaishouEticketResults
|
||||
:kuaishou-eticket-detail-result="kuaishouEticketDetailResult"
|
||||
:kuaishou-eticket-consume-result="kuaishouEticketConsumeResult"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
-814
@@ -1,814 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Check, Plus, RefreshRight, Search } from '@element-plus/icons-vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
createAdminKuaishouFeifeiTestOrder,
|
||||
fetchAdminKuaishouFeifeiConfig,
|
||||
fetchAdminKuaishouFeifeiProducts,
|
||||
queryAdminKuaishouFeifeiOrder,
|
||||
saveAdminKuaishouFeifeiConfig,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminKuaishouFeifeiConfig,
|
||||
AdminKuaishouFeifeiConfigResponse,
|
||||
AdminKuaishouFeifeiEffectiveConfig,
|
||||
AdminKuaishouFeifeiOrderResult,
|
||||
AdminKuaishouFeifeiProductItem,
|
||||
AdminKuaishouFeifeiProductRule,
|
||||
} from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
|
||||
const emit = defineEmits<{
|
||||
loaded: [data: AdminKuaishouFeifeiConfigResponse]
|
||||
saved: [data: AdminKuaishouFeifeiConfigResponse]
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const productLoading = ref(false)
|
||||
const orderCreating = ref(false)
|
||||
const orderQuerying = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const productErrorMessage = ref('')
|
||||
const orderErrorMessage = ref('')
|
||||
const filePath = ref('')
|
||||
const products = ref<AdminKuaishouFeifeiProductItem[]>([])
|
||||
const productTotal = ref(0)
|
||||
const productRules = ref<AdminKuaishouFeifeiProductRule[]>([])
|
||||
const lastOrder = ref<AdminKuaishouFeifeiOrderResult | null>(null)
|
||||
const lastOrderAction = ref('')
|
||||
const effective = ref<AdminKuaishouFeifeiEffectiveConfig>({
|
||||
enabled: true,
|
||||
baseUrl: '',
|
||||
timeoutMs: 10000,
|
||||
notifyUrl: '',
|
||||
hasAppKey: false,
|
||||
hasAppSecret: false,
|
||||
productRuleCount: 0,
|
||||
})
|
||||
const form = reactive<Omit<AdminKuaishouFeifeiConfig, 'productRules'>>({
|
||||
enabled: true,
|
||||
baseUrl: '',
|
||||
appKey: '',
|
||||
appSecret: '',
|
||||
timeoutMs: 10000,
|
||||
notifyUrl: '',
|
||||
})
|
||||
const productQuery = reactive({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
status: 'on_sale',
|
||||
supplyProductName: '',
|
||||
})
|
||||
const testOrder = reactive({
|
||||
platformOrderNo: createTestOrderNo(),
|
||||
productCode: '',
|
||||
platformBuyNum: 1,
|
||||
platformAmount: null as number | null,
|
||||
playerAccount: '',
|
||||
playerGameRegion: '',
|
||||
playerGameSrv: '',
|
||||
playerGameRole: '',
|
||||
submitPlayer: false,
|
||||
notifyUrl: '',
|
||||
})
|
||||
const queryOrder = reactive({
|
||||
platformOrderNo: '',
|
||||
orderNo: '',
|
||||
})
|
||||
|
||||
const credentialReady = computed(() => Boolean(form.appKey.trim() && form.appSecret.trim()))
|
||||
const effectiveReady = computed(() => effective.value.enabled && effective.value.hasAppKey && effective.value.hasAppSecret)
|
||||
const selectedProduct = computed(
|
||||
() =>
|
||||
products.value.find((product: AdminKuaishouFeifeiProductItem) => {
|
||||
return product.productCode === testOrder.productCode
|
||||
}) || null,
|
||||
)
|
||||
const lastOrderJson = computed(() => {
|
||||
if (!lastOrder.value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return JSON.stringify(lastOrder.value.raw || lastOrder.value, null, 2)
|
||||
})
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
label: '运行状态',
|
||||
value: effectiveReady.value ? '已就绪' : '待配置',
|
||||
detail: form.enabled ? '启用中' : '已停用',
|
||||
},
|
||||
{
|
||||
label: 'API 凭据',
|
||||
value: credentialReady.value ? '已填写' : '缺失',
|
||||
detail: effective.value.hasAppKey && effective.value.hasAppSecret ? '生效配置可用' : '待补全',
|
||||
},
|
||||
{
|
||||
label: '联调商品',
|
||||
value: String(products.value.length),
|
||||
detail: productTotal.value > 0 ? `总计 ${productTotal.value} 条` : '未加载',
|
||||
},
|
||||
{
|
||||
label: '履约规则',
|
||||
value: String(productRules.value.filter((rule) => rule.enabled !== false).length),
|
||||
detail: `共 ${productRules.value.length} 条`,
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(loadConfig)
|
||||
|
||||
async function loadConfig() {
|
||||
if (!hasAdminRole('admin')) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminKuaishouFeifeiConfig()
|
||||
hydrateConfig(response.data)
|
||||
emit('loaded', response.data)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '读取 kuaishou-feifei 配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
saving.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await saveAdminKuaishouFeifeiConfig(buildPayload())
|
||||
hydrateConfig(response.data)
|
||||
emit('saved', response.data)
|
||||
showSuccess('kuaishou-feifei 平台配置已保存')
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '保存 kuaishou-feifei 配置失败'
|
||||
showError(errorMessage.value)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProducts() {
|
||||
productLoading.value = true
|
||||
productErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminKuaishouFeifeiProducts({
|
||||
page: productQuery.page,
|
||||
perPage: productQuery.perPage,
|
||||
status: productQuery.status,
|
||||
supplyProductName: productQuery.supplyProductName.trim(),
|
||||
})
|
||||
products.value = Array.isArray(response.data.items) ? response.data.items : []
|
||||
productTotal.value = Number(response.data.total || 0) || 0
|
||||
} catch (error) {
|
||||
products.value = []
|
||||
productTotal.value = 0
|
||||
productErrorMessage.value = error instanceof Error ? error.message : '查询 kuaishou-feifei 商品失败'
|
||||
showError(productErrorMessage.value)
|
||||
} finally {
|
||||
productLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTestOrder() {
|
||||
if (!testOrder.platformOrderNo.trim()) {
|
||||
showError('请输入测试业务单号')
|
||||
return
|
||||
}
|
||||
|
||||
if (!testOrder.productCode.trim()) {
|
||||
showError('请输入 feifei 商品编码')
|
||||
return
|
||||
}
|
||||
|
||||
orderCreating.value = true
|
||||
orderErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await createAdminKuaishouFeifeiTestOrder({
|
||||
platformOrderNo: testOrder.platformOrderNo.trim(),
|
||||
productCode: testOrder.productCode.trim(),
|
||||
platformBuyNum: Number(testOrder.platformBuyNum || 1) || 1,
|
||||
platformAmount: testOrder.platformAmount,
|
||||
playerAccount: testOrder.playerAccount.trim(),
|
||||
playerGameRegion: testOrder.playerGameRegion.trim(),
|
||||
playerGameSrv: testOrder.playerGameSrv.trim(),
|
||||
playerGameRole: testOrder.playerGameRole.trim(),
|
||||
submitPlayer: testOrder.submitPlayer,
|
||||
notifyUrl: testOrder.notifyUrl.trim(),
|
||||
})
|
||||
applyOrderResult(response.data, '创建结果')
|
||||
showSuccess('kuaishou-feifei 测试单已创建')
|
||||
} catch (error) {
|
||||
orderErrorMessage.value = error instanceof Error ? error.message : '创建 kuaishou-feifei 测试单失败'
|
||||
showError(orderErrorMessage.value)
|
||||
} finally {
|
||||
orderCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function queryTestOrder() {
|
||||
const platformOrderNo = queryOrder.platformOrderNo.trim() || testOrder.platformOrderNo.trim()
|
||||
const orderNo = queryOrder.orderNo.trim()
|
||||
|
||||
if (!platformOrderNo && !orderNo) {
|
||||
showError('请输入 feifei 平台单号或测试业务单号')
|
||||
return
|
||||
}
|
||||
|
||||
orderQuerying.value = true
|
||||
orderErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await queryAdminKuaishouFeifeiOrder({
|
||||
platformOrderNo,
|
||||
orderNo,
|
||||
})
|
||||
applyOrderResult(response.data, '查询结果')
|
||||
showSuccess('kuaishou-feifei 订单查询完成')
|
||||
} catch (error) {
|
||||
orderErrorMessage.value = error instanceof Error ? error.message : '查询 kuaishou-feifei 订单失败'
|
||||
showError(orderErrorMessage.value)
|
||||
} finally {
|
||||
orderQuerying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyOrderResult(order: AdminKuaishouFeifeiOrderResult, action: string) {
|
||||
lastOrder.value = order
|
||||
lastOrderAction.value = action
|
||||
queryOrder.platformOrderNo = order.platformOrderNo || testOrder.platformOrderNo
|
||||
queryOrder.orderNo = order.orderNo || queryOrder.orderNo
|
||||
}
|
||||
|
||||
function useProductForTest(product: AdminKuaishouFeifeiProductItem) {
|
||||
testOrder.productCode = product.productCode
|
||||
}
|
||||
|
||||
function resetTestOrderNo() {
|
||||
testOrder.platformOrderNo = createTestOrderNo()
|
||||
}
|
||||
|
||||
function hydrateConfig(data: AdminKuaishouFeifeiConfigResponse) {
|
||||
filePath.value = data.filePath || ''
|
||||
effective.value = data.effective
|
||||
form.enabled = data.source.enabled !== false
|
||||
form.baseUrl = data.source.baseUrl || ''
|
||||
form.appKey = data.source.appKey || ''
|
||||
form.appSecret = data.source.appSecret || ''
|
||||
form.timeoutMs = Number(data.source.timeoutMs || 10000) || 10000
|
||||
form.notifyUrl = data.source.notifyUrl || ''
|
||||
productRules.value = Array.isArray(data.source.productRules) ? data.source.productRules : []
|
||||
if (!testOrder.notifyUrl) {
|
||||
testOrder.notifyUrl = data.source.notifyUrl || ''
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(): AdminKuaishouFeifeiConfig {
|
||||
return {
|
||||
enabled: form.enabled,
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
appKey: form.appKey.trim(),
|
||||
appSecret: form.appSecret.trim(),
|
||||
timeoutMs: Number(form.timeoutMs || 10000) || 10000,
|
||||
notifyUrl: form.notifyUrl.trim(),
|
||||
productRules: productRules.value,
|
||||
}
|
||||
}
|
||||
|
||||
function formatPoints(value: unknown) {
|
||||
const numberValue = Number(value)
|
||||
return Number.isFinite(numberValue) ? numberValue.toLocaleString('zh-CN') : '-'
|
||||
}
|
||||
|
||||
function formatProductOption(product: AdminKuaishouFeifeiProductItem) {
|
||||
return `${product.productCode} · ${product.name || '-'}`
|
||||
}
|
||||
|
||||
function getProductTagType(product: AdminKuaishouFeifeiProductItem) {
|
||||
return product.status === 'on_sale' && product.supplyStatus === 'on_sale' ? 'success' : 'warning'
|
||||
}
|
||||
|
||||
function getOrderStatusType(status: unknown) {
|
||||
const normalized = Number(status || 0)
|
||||
if (normalized === 30) return 'success'
|
||||
if ([40, 50, 60].includes(normalized)) return 'danger'
|
||||
if ([17, 20].includes(normalized)) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function createTestOrderNo() {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:TZ.]/g, '')
|
||||
.slice(0, 14)
|
||||
|
||||
return `TEST-FEIFEI-${timestamp}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="feifei-platform-panel">
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
type="error"
|
||||
:title="errorMessage"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<section class="feifei-overview">
|
||||
<div v-for="metric in metrics" :key="metric.label" class="metric-card">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}</strong>
|
||||
<small>{{ metric.detail }}</small>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<el-button :icon="RefreshRight" :loading="loading" @click="loadConfig">刷新</el-button>
|
||||
<el-button type="primary" :icon="Check" :loading="saving" @click="saveConfig">
|
||||
保存平台配置
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="platform-layout">
|
||||
<aside class="config-panel">
|
||||
<div class="section-head">
|
||||
<h3>平台连接</h3>
|
||||
<p>{{ filePath || 'data/kuaishou-feifei-config.json' }}</p>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" class="stack-form">
|
||||
<el-form-item label="启用">
|
||||
<el-switch
|
||||
v-model="form.enabled"
|
||||
inline-prompt
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Base URL">
|
||||
<el-input v-model="form.baseUrl" placeholder="http://skin-exchange.yiquyou.icu" />
|
||||
</el-form-item>
|
||||
<el-form-item label="App Key">
|
||||
<el-input v-model="form.appKey" placeholder="kuaishou-feifei app key" />
|
||||
</el-form-item>
|
||||
<el-form-item label="App Secret">
|
||||
<el-input
|
||||
v-model="form.appSecret"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="kuaishou-feifei app secret"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="超时毫秒">
|
||||
<el-input-number
|
||||
v-model="form.timeoutMs"
|
||||
:min="1000"
|
||||
:step="1000"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="通知地址">
|
||||
<el-input v-model="form.notifyUrl" placeholder="可选,feifei 回调通知地址" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</aside>
|
||||
|
||||
<main class="test-panel">
|
||||
<div class="section-head">
|
||||
<h3>API 联调</h3>
|
||||
<p>{{ effective.baseUrl || form.baseUrl || '-' }}</p>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="productErrorMessage"
|
||||
:title="productErrorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="orderErrorMessage"
|
||||
:title="orderErrorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div class="product-toolbar">
|
||||
<el-input
|
||||
v-model="productQuery.supplyProductName"
|
||||
clearable
|
||||
placeholder="供货商品名称"
|
||||
@keyup.enter="loadProducts"
|
||||
/>
|
||||
<el-select v-model="productQuery.status" class="status-select">
|
||||
<el-option label="在售" value="on_sale" />
|
||||
<el-option label="下架" value="off_sale" />
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="全部" value="all" />
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-model="productQuery.perPage"
|
||||
:min="1"
|
||||
:max="100"
|
||||
controls-position="right"
|
||||
/>
|
||||
<el-button :icon="Search" :loading="productLoading" @click="loadProducts">
|
||||
查询商品
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="productLoading"
|
||||
:data="products"
|
||||
border
|
||||
stripe
|
||||
height="320"
|
||||
empty-text="暂无商品"
|
||||
class="product-table"
|
||||
@row-click="useProductForTest"
|
||||
>
|
||||
<el-table-column label="商品" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="product-cell">
|
||||
<strong>{{ row.name || '-' }}</strong>
|
||||
<small>{{ row.productCode || '-' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="渠道" prop="channel" width="120" />
|
||||
<el-table-column label="扣费积分" width="120">
|
||||
<template #default="{ row }">{{ formatPoints(row.unitCostPoints) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单次数量" prop="maxOrderQuantity" width="110" />
|
||||
<el-table-column label="状态" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getProductTagType(row)" effect="light">
|
||||
{{ row.status || '-' }} / {{ row.supplyStatus || '-' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" plain @click.stop="useProductForTest(row)">选用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<section class="order-grid">
|
||||
<div class="order-panel">
|
||||
<div class="section-head">
|
||||
<h3>创建测试单</h3>
|
||||
</div>
|
||||
<el-form label-position="top" class="order-form">
|
||||
<el-form-item label="测试业务单号">
|
||||
<div class="inline-field">
|
||||
<el-input v-model="testOrder.platformOrderNo" />
|
||||
<el-button :icon="RefreshRight" @click="resetTestOrderNo" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="feifei 商品编码">
|
||||
<el-select
|
||||
v-model="testOrder.productCode"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="product_code"
|
||||
>
|
||||
<el-option
|
||||
v-for="product in products"
|
||||
:key="product.productCode"
|
||||
:label="formatProductOption(product)"
|
||||
:value="product.productCode"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数量">
|
||||
<el-input-number
|
||||
v-model="testOrder.platformBuyNum"
|
||||
:min="1"
|
||||
:max="selectedProduct?.maxOrderQuantity || 999"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="记录金额">
|
||||
<el-input-number
|
||||
v-model="testOrder.platformAmount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="玩家 UID">
|
||||
<el-input v-model="testOrder.playerAccount" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="区服-系统">
|
||||
<el-input v-model="testOrder.playerGameRegion" clearable placeholder="安卓 / 苹果" />
|
||||
</el-form-item>
|
||||
<el-form-item label="区服-渠道">
|
||||
<el-input v-model="testOrder.playerGameSrv" clearable placeholder="微信 / QQ" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名">
|
||||
<el-input v-model="testOrder.playerGameRole" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="直接提交玩家信息">
|
||||
<el-switch v-model="testOrder.submitPlayer" active-text="提交" inactive-text="不提交" />
|
||||
</el-form-item>
|
||||
<el-form-item label="通知地址">
|
||||
<el-input v-model="testOrder.notifyUrl" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
:loading="orderCreating"
|
||||
@click="createTestOrder"
|
||||
>
|
||||
创建测试单
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="order-panel">
|
||||
<div class="section-head">
|
||||
<h3>查询订单</h3>
|
||||
</div>
|
||||
<el-form label-position="top" class="order-form">
|
||||
<el-form-item label="测试业务单号">
|
||||
<el-input v-model="queryOrder.platformOrderNo" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="feifei 平台单号">
|
||||
<el-input v-model="queryOrder.orderNo" clearable />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button :icon="Search" :loading="orderQuerying" @click="queryTestOrder">
|
||||
查询订单
|
||||
</el-button>
|
||||
|
||||
<div v-if="lastOrder" class="order-result">
|
||||
<div class="result-head">
|
||||
<strong>{{ lastOrderAction || '订单结果' }}</strong>
|
||||
<el-tag :type="getOrderStatusType(lastOrder.rechargeStatus)" effect="light">
|
||||
{{ lastOrder.rechargeStatusLabel || lastOrder.rechargeStatus || '-' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>平台单号</dt>
|
||||
<dd>{{ lastOrder.orderNo || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>业务单号</dt>
|
||||
<dd>{{ lastOrder.platformOrderNo || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>商品</dt>
|
||||
<dd>{{ lastOrder.productName || lastOrder.productCode || '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>扣除积分</dt>
|
||||
<dd>{{ formatPoints(lastOrder.pointsCharged) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<el-link
|
||||
v-if="lastOrder.h5?.rechargeUrl"
|
||||
:href="lastOrder.h5.rechargeUrl"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
打开领取链接
|
||||
</el-link>
|
||||
<pre v-if="lastOrderJson" class="json-preview">{{ lastOrderJson }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feifei-platform-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feifei-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(160px, auto);
|
||||
gap: var(--space-3);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.config-panel,
|
||||
.test-panel,
|
||||
.order-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.metric-card small,
|
||||
.section-head p,
|
||||
.product-cell small,
|
||||
.order-result dt {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-2xl);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.overview-actions,
|
||||
.product-toolbar,
|
||||
.inline-field,
|
||||
.result-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.overview-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.platform-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.config-panel,
|
||||
.test-panel,
|
||||
.order-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.section-head {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.section-head h3 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-head p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stack-form,
|
||||
.order-form {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stack-form :deep(.el-input-number),
|
||||
.order-form :deep(.el-input-number),
|
||||
.order-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-toolbar,
|
||||
.inline-field {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 120px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.product-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-cell strong,
|
||||
.product-cell small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.order-form {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.inline-field .el-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.order-result {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.result-head {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.order-result dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.order-result dl div {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.order-result dt,
|
||||
.order-result dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.order-result dd {
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.json-preview {
|
||||
max-height: 220px;
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.feifei-overview,
|
||||
.platform-layout,
|
||||
.order-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overview-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.product-toolbar,
|
||||
.overview-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.status-select,
|
||||
.order-form {
|
||||
width: 100%;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditableNotificationRecipient } from '../../composables/types'
|
||||
|
||||
type NotificationForm = {
|
||||
enabled: boolean
|
||||
barkEnabled: boolean
|
||||
barkServerUrl: string
|
||||
wpushEnabled: boolean
|
||||
testTitle: string
|
||||
testBody: string
|
||||
testUrl: string
|
||||
}
|
||||
|
||||
type NotificationStats = {
|
||||
barkConfiguredRecipientCount: number
|
||||
barkEnabledRecipientCount: number
|
||||
wpushConfiguredRecipientCount: number
|
||||
wpushEnabledRecipientCount: number
|
||||
barkReady: boolean
|
||||
wpushReady: boolean
|
||||
ready: boolean
|
||||
lastSuccessCount: number
|
||||
lastFailedCount: number
|
||||
scheduledJobEnabledCount: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
notificationForm: NotificationForm
|
||||
notificationRecipients: EditableNotificationRecipient[]
|
||||
notificationSaving: boolean
|
||||
notificationResultError: string
|
||||
notificationStats: NotificationStats
|
||||
addNotificationRecipient: () => void
|
||||
removeNotificationRecipient: (id: string) => void
|
||||
handleNotificationSaveConfig: () => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">Bark 通知</span>
|
||||
<p class="card-desc">
|
||||
已配置 {{ notificationStats.barkConfiguredRecipientCount }} 人,启用
|
||||
{{ notificationStats.barkEnabledRecipientCount }} 人。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button @click="addNotificationRecipient">新增接收人</el-button>
|
||||
<el-button
|
||||
:loading="notificationSaving"
|
||||
type="primary"
|
||||
@click="handleNotificationSaveConfig"
|
||||
>保存配置</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="notificationResultError"
|
||||
:title="notificationResultError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-form label-width="auto" label-position="top" inline>
|
||||
<el-form-item
|
||||
><el-switch v-model="notificationForm.enabled" active-text="启用内部通知"
|
||||
/></el-form-item>
|
||||
<el-form-item
|
||||
><el-switch v-model="notificationForm.barkEnabled" active-text="启用 Bark 通道"
|
||||
/></el-form-item>
|
||||
<el-form-item label="Bark 服务地址" class="w-full">
|
||||
<el-input v-model="notificationForm.barkServerUrl" placeholder="https://api.day.app" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="notificationRecipients" stripe size="small" class="mt-4">
|
||||
<el-table-column label="接收人" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name" placeholder="例如:客服A" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Device Key" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.deviceKey" placeholder="Bark device key" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" text @click="removeNotificationRecipient(row.id)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="还没有 Bark 接收人。" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
type Props = {
|
||||
notificationFilePath: string
|
||||
scheduledJobsFilePath: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<span class="card-title">配置信息</span>
|
||||
</template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="职责"
|
||||
>内部运营通知,仅发送给后台管理员、客服或值班人员。</el-descriptions-item
|
||||
>
|
||||
<el-descriptions-item label="通道"
|
||||
>当前支持 Bark 与 WPush,两种通道可同时启用。</el-descriptions-item
|
||||
>
|
||||
<el-descriptions-item label="配置文件">{{
|
||||
notificationFilePath || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="监控任务">{{
|
||||
scheduledJobsFilePath || '-'
|
||||
}}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
</style>
|
||||
-548
@@ -1,548 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type {
|
||||
AdminCloudtentaclesMonitorAccount,
|
||||
AdminScheduledJobAccountRuntime,
|
||||
AdminScheduledJobRuntimeState,
|
||||
} from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import type {
|
||||
EditableScheduledJob,
|
||||
EditableScheduledJobAccount,
|
||||
} from '../../composables/types'
|
||||
|
||||
const timeUnitOptions = [
|
||||
{ label: '秒', value: 1 },
|
||||
{ label: '分钟', value: 60 },
|
||||
{ label: '小时', value: 3600 },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
scheduledJobsForm: {
|
||||
enabled: boolean
|
||||
}
|
||||
scheduledJobs: EditableScheduledJob[]
|
||||
scheduledJobRuntime: AdminScheduledJobRuntimeState[]
|
||||
cloudtentaclesMonitorAccounts: AdminCloudtentaclesMonitorAccount[]
|
||||
scheduledJobsSaving: boolean
|
||||
scheduledJobRunningId: string
|
||||
handleScheduledJobsSaveConfig: () => void | Promise<void>
|
||||
handleScheduledJobRunNow: (jobId: string) => void | Promise<void>
|
||||
getScheduledJobRuntime: (jobId: string) => AdminScheduledJobRuntimeState | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
function formatJobType(type: string) {
|
||||
if (type === 'cloudtentacles_health') return 'cloudtentacles 健康检查'
|
||||
return type || '-'
|
||||
}
|
||||
|
||||
function resolveTimeValue(seconds: number) {
|
||||
const normalized = Math.max(0, Number(seconds || 0))
|
||||
if (normalized >= 3600 && normalized % 3600 === 0) {
|
||||
return { amount: normalized / 3600, unit: 3600 }
|
||||
}
|
||||
if (normalized >= 60 && normalized % 60 === 0) {
|
||||
return { amount: normalized / 60, unit: 60 }
|
||||
}
|
||||
return { amount: normalized || 1, unit: 1 }
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const value = resolveTimeValue(seconds)
|
||||
const unit = timeUnitOptions.find((item) => item.value === value.unit)?.label || '秒'
|
||||
return `${value.amount} ${unit}`
|
||||
}
|
||||
|
||||
function bindDuration(job: EditableScheduledJob, key: 'intervalSeconds' | 'cooldownSeconds') {
|
||||
const amountKey = `${key}Amount` as keyof EditableScheduledJob
|
||||
const unitKey = `${key}Unit` as keyof EditableScheduledJob
|
||||
|
||||
if (!Number(job[amountKey])) {
|
||||
const resolved = resolveTimeValue(Number(job[key] || 0))
|
||||
;(job[amountKey] as number) = resolved.amount
|
||||
;(job[unitKey] as number) = resolved.unit
|
||||
}
|
||||
|
||||
return computed<number>({
|
||||
get: () => Number(job[amountKey] || 1),
|
||||
set: (value: number) => {
|
||||
;(job[amountKey] as number) = Math.max(1, Number(value || 1))
|
||||
job[key] = Number(job[amountKey] || 1) * Number(job[unitKey] || 1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function bindDurationUnit(job: EditableScheduledJob, key: 'intervalSeconds' | 'cooldownSeconds') {
|
||||
const amountKey = `${key}Amount` as keyof EditableScheduledJob
|
||||
const unitKey = `${key}Unit` as keyof EditableScheduledJob
|
||||
|
||||
if (!Number(job[unitKey])) {
|
||||
const resolved = resolveTimeValue(Number(job[key] || 0))
|
||||
;(job[amountKey] as number) = resolved.amount
|
||||
;(job[unitKey] as number) = resolved.unit
|
||||
}
|
||||
|
||||
return computed<number>({
|
||||
get: () => Number(job[unitKey] || 1),
|
||||
set: (value: number) => {
|
||||
;(job[unitKey] as number) = Math.max(1, Number(value || 1))
|
||||
job[key] = Number(job[amountKey] || 1) * Number(job[unitKey] || 1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getMonitorAccount(sourceKey: string) {
|
||||
return props.cloudtentaclesMonitorAccounts.find((item) => item.sourceKey === sourceKey) || null
|
||||
}
|
||||
|
||||
function getAccountRuntime(
|
||||
runtime: AdminScheduledJobRuntimeState | null,
|
||||
account: EditableScheduledJobAccount,
|
||||
) {
|
||||
return runtime?.lastAccounts?.find((item) => item.sourceKey === account.sourceKey) || null
|
||||
}
|
||||
|
||||
function formatStatus(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (normalized === 'ok') return '正常'
|
||||
if (normalized === 'asset_low') return '余额预警'
|
||||
if (normalized === 'auth_missing') return '未登录'
|
||||
if (normalized === 'source_missing') return '账号缺失'
|
||||
if (normalized === 'disabled') return '已停用'
|
||||
if (normalized === 'skipped') return '已跳过'
|
||||
if (normalized === 'running') return '执行中'
|
||||
if (normalized === 'failed') return '检查失败'
|
||||
if (normalized === 'pending') return '待执行'
|
||||
return normalized || '待执行'
|
||||
}
|
||||
|
||||
function resolveStatusType(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
if (normalized === 'ok') return 'success'
|
||||
if (normalized === 'asset_low' || normalized === 'running') return 'warning'
|
||||
if (['auth_missing', 'source_missing', 'failed'].includes(normalized)) return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function formatAccountMeta(account: EditableScheduledJobAccount) {
|
||||
const source = getMonitorAccount(account.sourceKey)
|
||||
if (!source) return '账号配置不存在'
|
||||
|
||||
return [
|
||||
source.username ? `登录名 ${source.username}` : '',
|
||||
source.phoneMasked ? `手机 ${source.phoneMasked}` : '',
|
||||
source.hasToken ? 'Token 已保存' : '未登录',
|
||||
].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
function formatAccountRuntimeSummary(runtime: AdminScheduledJobAccountRuntime | null) {
|
||||
if (!runtime) return '尚未检查'
|
||||
if (runtime.skipped) return runtime.message || '已跳过'
|
||||
return runtime.message || formatStatus(runtime.status)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">监控任务</span>
|
||||
<p class="card-desc">
|
||||
监控任务会按账号分别检查 cloudtentacles 登录态和余额;每个账号可设置独立阈值。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button
|
||||
:loading="scheduledJobsSaving"
|
||||
type="primary"
|
||||
@click="handleScheduledJobsSaveConfig"
|
||||
>保存任务</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item>
|
||||
<el-switch v-model="scheduledJobsForm.enabled" active-text="启用监控任务系统" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="job-list">
|
||||
<section v-for="job in scheduledJobs" :key="job.id" class="job-panel">
|
||||
<div class="job-panel-head">
|
||||
<div>
|
||||
<div class="job-title-row">
|
||||
<span class="fw-600">{{ formatJobType(job.type) }}</span>
|
||||
<el-tag size="small" :type="job.enabled ? 'success' : 'info'">
|
||||
{{ job.enabled ? '已启用' : '已停用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="job-subtitle">
|
||||
{{ getScheduledJobRuntime(job.id)?.lastMessage || '等待首次执行' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="job-actions">
|
||||
<el-switch v-model="job.enabled" active-text="启用任务" />
|
||||
<el-button
|
||||
:loading="scheduledJobRunningId === job.id"
|
||||
size="small"
|
||||
@click="handleScheduledJobRunNow(job.id)"
|
||||
>
|
||||
立即检查
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="job-settings">
|
||||
<label class="duration-field">
|
||||
<span class="param-label">检查间隔</span>
|
||||
<span class="duration-controls">
|
||||
<el-input-number
|
||||
v-model.number="bindDuration(job, 'intervalSeconds').value"
|
||||
:min="1"
|
||||
class="duration-input"
|
||||
controls-position="right"
|
||||
/>
|
||||
<el-select
|
||||
v-model.number="bindDurationUnit(job, 'intervalSeconds').value"
|
||||
class="duration-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="unit in timeUnitOptions"
|
||||
:key="unit.value"
|
||||
:label="unit.label"
|
||||
:value="unit.value"
|
||||
>
|
||||
{{ unit.label }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
</span>
|
||||
<span class="param-hint">每 {{ formatDuration(job.intervalSeconds) }} 检查一次</span>
|
||||
</label>
|
||||
|
||||
<label class="duration-field">
|
||||
<span class="param-label">通知冷却</span>
|
||||
<span class="duration-controls">
|
||||
<el-input-number
|
||||
v-model.number="bindDuration(job, 'cooldownSeconds').value"
|
||||
:min="1"
|
||||
class="duration-input"
|
||||
controls-position="right"
|
||||
/>
|
||||
<el-select
|
||||
v-model.number="bindDurationUnit(job, 'cooldownSeconds').value"
|
||||
class="duration-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="unit in timeUnitOptions"
|
||||
:key="unit.value"
|
||||
:label="unit.label"
|
||||
:value="unit.value"
|
||||
>
|
||||
{{ unit.label }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
</span>
|
||||
<span class="param-hint"
|
||||
>同一账号同类异常 {{ formatDuration(job.cooldownSeconds) }} 内只通知一次</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="runtime-summary">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">任务状态</span>
|
||||
<el-tag
|
||||
:type="resolveStatusType(getScheduledJobRuntime(job.id)?.lastStatus || 'pending')"
|
||||
size="small"
|
||||
>
|
||||
{{ formatStatus(getScheduledJobRuntime(job.id)?.lastStatus || 'pending') }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">账号</span>
|
||||
<span class="summary-value"
|
||||
><strong>{{ getScheduledJobRuntime(job.id)?.lastCheckedCount ?? 0 }}</strong>
|
||||
<span class="summary-muted">/ {{ job.accounts.length }} 已检查</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">预警</span>
|
||||
<span class="summary-value"
|
||||
><strong>{{ getScheduledJobRuntime(job.id)?.lastLowAssetCount ?? 0 }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">异常</span>
|
||||
<span class="summary-value"
|
||||
><strong>{{ getScheduledJobRuntime(job.id)?.lastFailedCount ?? 0 }}</strong></span
|
||||
>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">上次</span>
|
||||
<span class="summary-value">{{
|
||||
formatAdminDateTime(getScheduledJobRuntime(job.id)?.lastRunAt || '')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">下次</span>
|
||||
<span class="summary-value">{{
|
||||
formatAdminDateTime(getScheduledJobRuntime(job.id)?.nextRunAt || '')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="job.accounts" size="small" class="accounts-table">
|
||||
<el-table-column label="账号" min-width="230">
|
||||
<template #default="{ row: account }">
|
||||
<div class="account-title">{{ account.label || account.sourceKey }}</div>
|
||||
<div class="account-meta">{{ account.sourceKey }}</div>
|
||||
<div class="account-meta">{{ formatAccountMeta(account) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="监控" width="110" align="center">
|
||||
<template #default="{ row: account }">
|
||||
<el-switch v-model="account.enabled" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="余额阈值" width="150">
|
||||
<template #default="{ row: account }">
|
||||
<el-input-number
|
||||
v-model.number="account.assetThreshold"
|
||||
:min="0"
|
||||
class="threshold-input"
|
||||
controls-position="right"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近结果" min-width="260">
|
||||
<template #default="{ row: account }">
|
||||
<div
|
||||
v-if="getAccountRuntime(getScheduledJobRuntime(job.id), account)"
|
||||
class="account-runtime"
|
||||
>
|
||||
<div class="runtime-line">
|
||||
<el-tag
|
||||
:type="
|
||||
resolveStatusType(
|
||||
getAccountRuntime(getScheduledJobRuntime(job.id), account)?.status ||
|
||||
'pending',
|
||||
)
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
formatStatus(
|
||||
getAccountRuntime(getScheduledJobRuntime(job.id), account)?.status ||
|
||||
'pending',
|
||||
)
|
||||
}}
|
||||
</el-tag>
|
||||
<span>
|
||||
余额
|
||||
{{ getAccountRuntime(getScheduledJobRuntime(job.id), account)?.asset ?? '-' }}
|
||||
/ 阈值
|
||||
{{
|
||||
getAccountRuntime(getScheduledJobRuntime(job.id), account)?.threshold ??
|
||||
account.assetThreshold
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="account-meta">
|
||||
{{ formatAccountRuntimeSummary(getAccountRuntime(getScheduledJobRuntime(job.id), account)) }}
|
||||
</div>
|
||||
<div class="account-meta">
|
||||
{{
|
||||
formatAdminDateTime(
|
||||
getAccountRuntime(getScheduledJobRuntime(job.id), account)?.checkedAt || '',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="account-meta">尚未检查</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无 cloudtentacles 账号,请先在履约平台配置账号。" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.card-actions,
|
||||
.job-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.job-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.job-panel {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.job-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.job-title-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.job-subtitle,
|
||||
.param-hint,
|
||||
.summary-muted,
|
||||
.account-meta {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.job-settings {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.duration-field {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(230px, 300px) minmax(180px, 1fr);
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
.param-label,
|
||||
.summary-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.duration-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 144px 96px;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 144px;
|
||||
}
|
||||
|
||||
.duration-select {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.runtime-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: grid;
|
||||
grid-template-rows: 18px minmax(24px, auto);
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.accounts-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.account-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.threshold-input {
|
||||
width: 144px;
|
||||
}
|
||||
|
||||
.account-runtime {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.runtime-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.job-panel-head {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.job-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.duration-field {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.duration-controls {
|
||||
grid-template-columns: minmax(0, 1fr) 96px;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNotificationTestResult } from '@/types/admin'
|
||||
|
||||
type NotificationForm = {
|
||||
enabled: boolean
|
||||
barkEnabled: boolean
|
||||
barkServerUrl: string
|
||||
wpushEnabled: boolean
|
||||
testTitle: string
|
||||
testBody: string
|
||||
testUrl: string
|
||||
}
|
||||
|
||||
type NotificationStats = {
|
||||
barkConfiguredRecipientCount: number
|
||||
barkEnabledRecipientCount: number
|
||||
wpushConfiguredRecipientCount: number
|
||||
wpushEnabledRecipientCount: number
|
||||
barkReady: boolean
|
||||
wpushReady: boolean
|
||||
ready: boolean
|
||||
lastSuccessCount: number
|
||||
lastFailedCount: number
|
||||
scheduledJobEnabledCount: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
notificationForm: NotificationForm
|
||||
notificationTesting: boolean
|
||||
notificationTestResult: AdminNotificationTestResult | null
|
||||
notificationStats: NotificationStats
|
||||
handleNotificationTest: () => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">测试发送</span>
|
||||
<p class="card-desc">测试会发送给所有已启用且已填写 Bark Device Key 或 WPush API Key 的接收人。</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button
|
||||
:disabled="!notificationStats.ready"
|
||||
:loading="notificationTesting"
|
||||
type="primary"
|
||||
@click="handleNotificationTest"
|
||||
>
|
||||
发送测试
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-width="auto" label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="notificationForm.testTitle" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="notificationForm.testBody" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="跳转链接">
|
||||
<el-input v-model="notificationForm.testUrl" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div v-if="notificationTestResult" class="result-grid">
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-label">成功</div>
|
||||
<div class="stat-value">{{ notificationTestResult.successCount }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-label">失败</div>
|
||||
<div class="stat-value">{{ notificationTestResult.failedCount }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="stat-card">
|
||||
<div class="stat-label">通道</div>
|
||||
<div class="stat-value">{{ notificationTestResult.channel || '-' }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-if="notificationTestResult"
|
||||
:data="notificationTestResult.results"
|
||||
stripe
|
||||
size="small"
|
||||
class="mt-4"
|
||||
>
|
||||
<el-table-column prop="channel" label="通道" width="100" />
|
||||
<el-table-column label="接收人">
|
||||
<template #default="{ row }">{{
|
||||
row.recipientName || row.recipientKeyMasked || '-'
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.ok ? 'success' : 'danger'" size="small">{{
|
||||
row.ok ? '成功' : '失败'
|
||||
}}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态码" width="80" align="center" />
|
||||
<el-table-column label="说明">
|
||||
<template #default="{ row }">{{ row.errorMessage || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
</style>
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditableWpushRecipient } from '../../composables/types'
|
||||
|
||||
type NotificationForm = {
|
||||
enabled: boolean
|
||||
barkEnabled: boolean
|
||||
barkServerUrl: string
|
||||
wpushEnabled: boolean
|
||||
testTitle: string
|
||||
testBody: string
|
||||
testUrl: string
|
||||
}
|
||||
|
||||
type NotificationStats = {
|
||||
barkConfiguredRecipientCount: number
|
||||
barkEnabledRecipientCount: number
|
||||
wpushConfiguredRecipientCount: number
|
||||
wpushEnabledRecipientCount: number
|
||||
barkReady: boolean
|
||||
wpushReady: boolean
|
||||
ready: boolean
|
||||
lastSuccessCount: number
|
||||
lastFailedCount: number
|
||||
scheduledJobEnabledCount: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
notificationForm: NotificationForm
|
||||
wpushRecipients: EditableWpushRecipient[]
|
||||
notificationSaving: boolean
|
||||
notificationResultError: string
|
||||
notificationStats: NotificationStats
|
||||
addNotificationRecipient: () => void
|
||||
removeNotificationRecipient: (id: string) => void
|
||||
handleNotificationSaveConfig: () => void | Promise<void>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<span class="card-title">WPush 通知</span>
|
||||
<p class="card-desc">
|
||||
已配置 {{ notificationStats.wpushConfiguredRecipientCount }} 人,启用
|
||||
{{ notificationStats.wpushEnabledRecipientCount }} 人。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button @click="addNotificationRecipient">新增接收人</el-button>
|
||||
<el-button
|
||||
:loading="notificationSaving"
|
||||
type="primary"
|
||||
@click="handleNotificationSaveConfig"
|
||||
>
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="notificationResultError"
|
||||
:title="notificationResultError"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-form label-width="auto" label-position="top" inline>
|
||||
<el-form-item>
|
||||
<el-switch v-model="notificationForm.wpushEnabled" active-text="启用 WPush 通道" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="wpushRecipients" stripe size="small" class="mt-4">
|
||||
<el-table-column label="接收人" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name" placeholder="例如:值班手机" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="API Key" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.apiKey" placeholder="WPush apikey" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch v-model="row.enabled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" text @click="removeNotificationRecipient(row.id)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="还没有 WPush 接收人。" :image-size="60" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AdminCloudtentaclesMonitorAccount,
|
||||
AdminNotificationTestResult,
|
||||
AdminScheduledJobRuntimeState,
|
||||
} from '@/types/admin'
|
||||
import type {
|
||||
EditableNotificationRecipient,
|
||||
EditableScheduledJob,
|
||||
EditableWpushRecipient,
|
||||
} from '../../composables/types'
|
||||
import AdminNotificationInfoCard from './AdminNotificationInfoCard.vue'
|
||||
import AdminNotificationBarkCard from './AdminNotificationBarkCard.vue'
|
||||
import AdminNotificationWpushCard from './AdminNotificationWpushCard.vue'
|
||||
import AdminNotificationScheduledJobsCard from './AdminNotificationScheduledJobsCard.vue'
|
||||
import AdminNotificationTestCard from './AdminNotificationTestCard.vue'
|
||||
|
||||
type NotificationForm = {
|
||||
enabled: boolean
|
||||
barkEnabled: boolean
|
||||
barkServerUrl: string
|
||||
wpushEnabled: boolean
|
||||
testTitle: string
|
||||
testBody: string
|
||||
testUrl: string
|
||||
}
|
||||
|
||||
type ScheduledJobsForm = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
type NotificationStats = {
|
||||
barkConfiguredRecipientCount: number
|
||||
barkEnabledRecipientCount: number
|
||||
wpushConfiguredRecipientCount: number
|
||||
wpushEnabledRecipientCount: number
|
||||
barkReady: boolean
|
||||
wpushReady: boolean
|
||||
ready: boolean
|
||||
lastSuccessCount: number
|
||||
lastFailedCount: number
|
||||
scheduledJobEnabledCount: number
|
||||
}
|
||||
|
||||
type Props = {
|
||||
notificationFilePath: string
|
||||
notificationForm: NotificationForm
|
||||
notificationRecipients: EditableNotificationRecipient[]
|
||||
wpushRecipients: EditableWpushRecipient[]
|
||||
notificationSaving: boolean
|
||||
notificationTesting: boolean
|
||||
notificationResultError: string
|
||||
notificationTestResult: AdminNotificationTestResult | null
|
||||
scheduledJobsFilePath: string
|
||||
scheduledJobsForm: ScheduledJobsForm
|
||||
scheduledJobs: EditableScheduledJob[]
|
||||
scheduledJobRuntime: AdminScheduledJobRuntimeState[]
|
||||
cloudtentaclesMonitorAccounts: AdminCloudtentaclesMonitorAccount[]
|
||||
scheduledJobsSaving: boolean
|
||||
scheduledJobRunningId: string
|
||||
notificationStats: NotificationStats
|
||||
addNotificationRecipient: (channel: 'bark' | 'wpush') => void
|
||||
removeNotificationRecipient: (channel: 'bark' | 'wpush', id: string) => void
|
||||
handleNotificationSaveConfig: () => void | Promise<void>
|
||||
handleNotificationTest: () => void | Promise<void>
|
||||
handleScheduledJobsSaveConfig: () => void | Promise<void>
|
||||
handleScheduledJobRunNow: (jobId: string) => void | Promise<void>
|
||||
getScheduledJobRuntime: (jobId: string) => AdminScheduledJobRuntimeState | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="platform-section">
|
||||
<AdminNotificationInfoCard
|
||||
:notification-file-path="notificationFilePath"
|
||||
:scheduled-jobs-file-path="scheduledJobsFilePath"
|
||||
/>
|
||||
|
||||
<AdminNotificationBarkCard
|
||||
:notification-form="notificationForm"
|
||||
:notification-recipients="notificationRecipients"
|
||||
:notification-saving="notificationSaving"
|
||||
:notification-result-error="notificationResultError"
|
||||
:notification-stats="notificationStats"
|
||||
:add-notification-recipient="() => addNotificationRecipient('bark')"
|
||||
:remove-notification-recipient="(id) => removeNotificationRecipient('bark', id)"
|
||||
:handle-notification-save-config="handleNotificationSaveConfig"
|
||||
/>
|
||||
|
||||
<AdminNotificationWpushCard
|
||||
:notification-form="notificationForm"
|
||||
:wpush-recipients="wpushRecipients"
|
||||
:notification-saving="notificationSaving"
|
||||
:notification-result-error="notificationResultError"
|
||||
:notification-stats="notificationStats"
|
||||
:add-notification-recipient="() => addNotificationRecipient('wpush')"
|
||||
:remove-notification-recipient="(id) => removeNotificationRecipient('wpush', id)"
|
||||
:handle-notification-save-config="handleNotificationSaveConfig"
|
||||
/>
|
||||
|
||||
<AdminNotificationScheduledJobsCard
|
||||
:scheduled-jobs-form="scheduledJobsForm"
|
||||
:scheduled-jobs="scheduledJobs"
|
||||
:scheduled-job-runtime="scheduledJobRuntime"
|
||||
:cloudtentacles-monitor-accounts="cloudtentaclesMonitorAccounts"
|
||||
:scheduled-jobs-saving="scheduledJobsSaving"
|
||||
:scheduled-job-running-id="scheduledJobRunningId"
|
||||
:handle-scheduled-jobs-save-config="handleScheduledJobsSaveConfig"
|
||||
:handle-scheduled-job-run-now="handleScheduledJobRunNow"
|
||||
:get-scheduled-job-runtime="getScheduledJobRuntime"
|
||||
/>
|
||||
|
||||
<AdminNotificationTestCard
|
||||
:notification-form="notificationForm"
|
||||
:notification-testing="notificationTesting"
|
||||
:notification-test-result="notificationTestResult"
|
||||
:notification-stats="notificationStats"
|
||||
:handle-notification-test="handleNotificationTest"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +0,0 @@
|
||||
export const DEFAULT_CLOUDTENTACLES_DEVICE_ID =
|
||||
'08bc9d8c-fd15-48ea-bc00-8d754076cafc'
|
||||
export const DEFAULT_CLOUDTENTACLES_DEVICE_TYPE = 1
|
||||
|
||||
export function normalizeCloudtentaclesDeviceId(value: unknown) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized && normalized !== '-' ? normalized : DEFAULT_CLOUDTENTACLES_DEVICE_ID
|
||||
}
|
||||
|
||||
export function normalizeCloudtentaclesDeviceType(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_CLOUDTENTACLES_DEVICE_TYPE
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
export type PlatformTab =
|
||||
| 'notifications'
|
||||
| 'ninetyone'
|
||||
| 'kuaishouEticket'
|
||||
| 'kuaishouFeifei'
|
||||
| 'cloudtentacles'
|
||||
|
||||
export type EditableKuaishouEticketShop = {
|
||||
id: string
|
||||
shopId: string
|
||||
kshopName: string
|
||||
cookie: string
|
||||
userAvatar: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type EditableNotificationRecipient = {
|
||||
id: string
|
||||
name: string
|
||||
deviceKey: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type EditableWpushRecipient = {
|
||||
id: string
|
||||
name: string
|
||||
apiKey: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type EditableScheduledJob = {
|
||||
id: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
intervalSeconds: number
|
||||
intervalSecondsAmount: number
|
||||
intervalSecondsUnit: number
|
||||
cooldownSeconds: number
|
||||
cooldownSecondsAmount: number
|
||||
cooldownSecondsUnit: number
|
||||
assetThreshold: number
|
||||
accounts: EditableScheduledJobAccount[]
|
||||
}
|
||||
|
||||
export type EditableScheduledJobAccount = {
|
||||
sourceKey: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
assetThreshold: number
|
||||
}
|
||||
-498
@@ -1,498 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { showError, showPrompt, showSuccess, isFeedbackDismissed } from '@/lib/feedback'
|
||||
import {
|
||||
deleteAdminCloudtentaclesSource,
|
||||
saveAdminCloudtentaclesSourceConfig,
|
||||
sendAdminCloudtentaclesSmsCode,
|
||||
testAdminCloudtentaclesLogin,
|
||||
validateAdminCloudtentaclesSession,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminCloudtentaclesLoginTestResult,
|
||||
AdminCloudtentaclesPersistedSession,
|
||||
AdminCloudtentaclesSendSmsResult,
|
||||
AdminCloudtentaclesSourceItem,
|
||||
AdminCloudtentaclesSessionsMap,
|
||||
AdminCloudtentaclesSourceConfigResponse,
|
||||
AdminCloudtentaclesSourcesConfig,
|
||||
AdminCloudtentaclesValidateSessionResult,
|
||||
} from '@/types/admin'
|
||||
|
||||
import type { CloudtentaclesForm } from './useCloudtentaclesDebugActions'
|
||||
import {
|
||||
DEFAULT_CLOUDTENTACLES_DEVICE_ID,
|
||||
DEFAULT_CLOUDTENTACLES_DEVICE_TYPE,
|
||||
normalizeCloudtentaclesDeviceId,
|
||||
normalizeCloudtentaclesDeviceType,
|
||||
} from './cloudtentaclesDefaults'
|
||||
import { useCloudtentaclesDebugActions } from './useCloudtentaclesDebugActions'
|
||||
|
||||
export function useAdminCloudtentaclesPlatform() {
|
||||
const cloudtentaclesFilePath = ref('')
|
||||
const cloudtentaclesSessionFilePath = ref('')
|
||||
const selectedSourceKey = ref('')
|
||||
|
||||
const sources = ref<AdminCloudtentaclesSourceItem[]>([])
|
||||
const sessions = ref<AdminCloudtentaclesSessionsMap>({})
|
||||
|
||||
const cloudtentaclesForm = ref<CloudtentaclesForm>({
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
username: '',
|
||||
password: '',
|
||||
phone: '',
|
||||
code: '',
|
||||
token: '',
|
||||
vnKey: '1',
|
||||
vnId: 0,
|
||||
skuId: 5,
|
||||
skuCount: 1,
|
||||
deviceId: DEFAULT_CLOUDTENTACLES_DEVICE_ID,
|
||||
deviceType: DEFAULT_CLOUDTENTACLES_DEVICE_TYPE,
|
||||
})
|
||||
const cloudtentaclesSaving = ref(false)
|
||||
const cloudtentaclesSendingSms = ref(false)
|
||||
const cloudtentaclesTesting = ref(false)
|
||||
const cloudtentaclesValidating = ref(false)
|
||||
const cloudtentaclesResultError = ref('')
|
||||
const cloudtentaclesSmsResult = ref<AdminCloudtentaclesSendSmsResult | null>(null)
|
||||
const cloudtentaclesLoginResult = ref<AdminCloudtentaclesLoginTestResult | null>(null)
|
||||
const cloudtentaclesValidateResult = ref<AdminCloudtentaclesValidateSessionResult | null>(null)
|
||||
const cloudtentaclesPersistedSession = ref<AdminCloudtentaclesPersistedSession | null>(null)
|
||||
|
||||
const debugActions = useCloudtentaclesDebugActions(
|
||||
cloudtentaclesForm,
|
||||
cloudtentaclesResultError,
|
||||
selectedSourceKey,
|
||||
)
|
||||
|
||||
const sourceOptions = computed(() => sources.value.map((s) => ({ key: s.key, label: s.label })))
|
||||
|
||||
const currentSource = computed(
|
||||
() => sources.value.find((s) => s.key === selectedSourceKey.value) || sources.value[0],
|
||||
)
|
||||
|
||||
const currentSession = computed(() => sessions.value[selectedSourceKey.value] || null)
|
||||
const currentSourceEnabled = computed({
|
||||
get: () => currentSource.value?.enabled !== false,
|
||||
set: (value: boolean) => {
|
||||
const source = sources.value.find((item) => item.key === selectedSourceKey.value)
|
||||
if (source) {
|
||||
source.enabled = value
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const cloudtentaclesStats = computed(() => ({
|
||||
hasCredential: Boolean(
|
||||
cloudtentaclesForm.value.username.trim() &&
|
||||
cloudtentaclesForm.value.password.trim() &&
|
||||
cloudtentaclesForm.value.phone.trim(),
|
||||
),
|
||||
smsSent: Boolean(cloudtentaclesSmsResult.value),
|
||||
validated: Boolean(cloudtentaclesValidateResult.value || cloudtentaclesLoginResult.value),
|
||||
sourceCount: sources.value.length,
|
||||
}))
|
||||
|
||||
function createSourceLabel(index: number) {
|
||||
return `履约账号 ${index}`
|
||||
}
|
||||
|
||||
function generateSourceKey() {
|
||||
let key = ''
|
||||
do {
|
||||
key = `account_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
||||
} while (sources.value.some((source) => source.key === key))
|
||||
return key
|
||||
}
|
||||
|
||||
function createEmptySource(label = createSourceLabel(sources.value.length + 1)) {
|
||||
return {
|
||||
key: generateSourceKey(),
|
||||
label,
|
||||
enabled: true,
|
||||
baseUrl: 'https://123.207.217.176',
|
||||
username: '',
|
||||
password: '',
|
||||
phone: '',
|
||||
deviceId: DEFAULT_CLOUDTENTACLES_DEVICE_ID,
|
||||
deviceType: DEFAULT_CLOUDTENTACLES_DEVICE_TYPE,
|
||||
} satisfies AdminCloudtentaclesSourceItem
|
||||
}
|
||||
|
||||
function resetCloudtentaclesTransientState() {
|
||||
cloudtentaclesSmsResult.value = null
|
||||
cloudtentaclesLoginResult.value = null
|
||||
cloudtentaclesValidateResult.value = null
|
||||
cloudtentaclesResultError.value = ''
|
||||
}
|
||||
|
||||
function persistCurrentFormToSelectedSource() {
|
||||
const source = sources.value.find((item) => item.key === selectedSourceKey.value)
|
||||
if (!source) return
|
||||
|
||||
source.baseUrl = cloudtentaclesForm.value.baseUrl.trim() || 'https://123.207.217.176'
|
||||
source.username = cloudtentaclesForm.value.username.trim()
|
||||
source.password = cloudtentaclesForm.value.password.trim()
|
||||
source.phone = cloudtentaclesForm.value.phone.trim()
|
||||
source.deviceId = normalizeCloudtentaclesDeviceId(cloudtentaclesForm.value.deviceId)
|
||||
source.deviceType = normalizeCloudtentaclesDeviceType(cloudtentaclesForm.value.deviceType)
|
||||
}
|
||||
|
||||
function selectFallbackSource(preferredKey = '') {
|
||||
const preferred = sources.value.find((source) => source.key === preferredKey)
|
||||
selectedSourceKey.value = preferred?.key || sources.value[0]?.key || ''
|
||||
}
|
||||
|
||||
function hydrateCloudtentaclesConfig(data: AdminCloudtentaclesSourceConfigResponse) {
|
||||
cloudtentaclesFilePath.value = data.filePath
|
||||
cloudtentaclesSessionFilePath.value = data.sessionFilePath
|
||||
sources.value = data.sources || []
|
||||
sessions.value = data.sessions || {}
|
||||
|
||||
if (!sources.value.length) {
|
||||
sources.value.push(createEmptySource())
|
||||
}
|
||||
|
||||
if (!sources.value.find((s) => s.key === selectedSourceKey.value)) {
|
||||
selectFallbackSource()
|
||||
}
|
||||
|
||||
hydrateFormFromSource(selectedSourceKey.value)
|
||||
}
|
||||
|
||||
function hydrateFormFromSource(sourceKey: string) {
|
||||
const source = sources.value.find((s) => s.key === sourceKey)
|
||||
const session = sessions.value[sourceKey]
|
||||
if (source) {
|
||||
cloudtentaclesForm.value = {
|
||||
baseUrl: source.baseUrl || 'https://123.207.217.176',
|
||||
username: source.username || '',
|
||||
password: source.password || '',
|
||||
phone: source.phone || '',
|
||||
code: '',
|
||||
token: session?.token || '',
|
||||
vnKey: '1',
|
||||
vnId: 0,
|
||||
skuId: 5,
|
||||
skuCount: 1,
|
||||
deviceId: normalizeCloudtentaclesDeviceId(source.deviceId),
|
||||
deviceType: normalizeCloudtentaclesDeviceType(source.deviceType),
|
||||
}
|
||||
}
|
||||
cloudtentaclesPersistedSession.value = session || null
|
||||
}
|
||||
|
||||
function switchSource(sourceKey: string) {
|
||||
if (sourceKey === selectedSourceKey.value) return
|
||||
if (!sources.value.some((source) => source.key === sourceKey)) return
|
||||
|
||||
persistCurrentFormToSelectedSource()
|
||||
selectedSourceKey.value = sourceKey
|
||||
resetCloudtentaclesTransientState()
|
||||
hydrateFormFromSource(sourceKey)
|
||||
}
|
||||
|
||||
async function addSource() {
|
||||
try {
|
||||
const result = await showPrompt(
|
||||
'请输入账号显示名称(例如:生产履约号、夜班履约号)',
|
||||
'新增 cloudtentacles 账号',
|
||||
{
|
||||
inputPattern: /\S/,
|
||||
inputErrorMessage: '账号名称不能为空',
|
||||
},
|
||||
)
|
||||
const trimmed = (result.value as string).trim()
|
||||
if (!trimmed) {
|
||||
showError('账号名称不能为空')
|
||||
return
|
||||
}
|
||||
persistCurrentFormToSelectedSource()
|
||||
const newSource = createEmptySource(trimmed)
|
||||
sources.value.push(newSource)
|
||||
selectedSourceKey.value = newSource.key
|
||||
resetCloudtentaclesTransientState()
|
||||
hydrateFormFromSource(newSource.key)
|
||||
} catch (error) {
|
||||
if (isFeedbackDismissed(error)) return // user cancelled / closed
|
||||
showError(error instanceof Error ? error.message : '新增账号失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSource(sourceKey: string) {
|
||||
const sourceIndex = sources.value.findIndex((source) => source.key === sourceKey)
|
||||
if (sourceIndex < 0) {
|
||||
showError('账号不存在')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
cloudtentaclesSaving.value = true
|
||||
persistCurrentFormToSelectedSource()
|
||||
|
||||
if (sources.value.length === 1) {
|
||||
const replacement = createEmptySource()
|
||||
const response = await saveAdminCloudtentaclesSourceConfig({
|
||||
enabled: true,
|
||||
sources: [replacement],
|
||||
})
|
||||
cloudtentaclesFilePath.value = response.data.filePath
|
||||
cloudtentaclesSessionFilePath.value = response.data.sessionFilePath
|
||||
sources.value = response.data.sources
|
||||
sessions.value = response.data.sessions
|
||||
selectFallbackSource(replacement.key)
|
||||
} else {
|
||||
await deleteAdminCloudtentaclesSource(sourceKey)
|
||||
sources.value = sources.value.filter((s) => s.key !== sourceKey)
|
||||
delete sessions.value[sourceKey]
|
||||
|
||||
if (selectedSourceKey.value === sourceKey) {
|
||||
const nextSource = sources.value[sourceIndex] || sources.value[sourceIndex - 1] || sources.value[0]
|
||||
selectFallbackSource(nextSource?.key)
|
||||
}
|
||||
}
|
||||
resetCloudtentaclesTransientState()
|
||||
hydrateFormFromSource(selectedSourceKey.value)
|
||||
showSuccess('账号已删除')
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '删除账号失败')
|
||||
} finally {
|
||||
cloudtentaclesSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildCloudtentaclesConfigPayload(): AdminCloudtentaclesSourcesConfig {
|
||||
persistCurrentFormToSelectedSource()
|
||||
return {
|
||||
enabled: true,
|
||||
sources: sources.value.map((s) => ({
|
||||
key: s.key,
|
||||
label: s.label,
|
||||
enabled: s.enabled !== false,
|
||||
baseUrl: s.baseUrl,
|
||||
username: s.username,
|
||||
password: s.password,
|
||||
phone: s.phone,
|
||||
deviceId: s.deviceId,
|
||||
deviceType: s.deviceType,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function buildCloudtentaclesLoginPayload() {
|
||||
return {
|
||||
...buildCloudtentaclesSourcePayload(),
|
||||
code: cloudtentaclesForm.value.code.trim(),
|
||||
sourceKey: selectedSourceKey.value,
|
||||
}
|
||||
}
|
||||
|
||||
function buildCloudtentaclesSourcePayload() {
|
||||
return {
|
||||
baseUrl: cloudtentaclesForm.value.baseUrl.trim() || 'https://123.207.217.176',
|
||||
username: cloudtentaclesForm.value.username.trim(),
|
||||
password: cloudtentaclesForm.value.password.trim(),
|
||||
phone: cloudtentaclesForm.value.phone.trim(),
|
||||
deviceId: normalizeCloudtentaclesDeviceId(cloudtentaclesForm.value.deviceId),
|
||||
deviceType: normalizeCloudtentaclesDeviceType(cloudtentaclesForm.value.deviceType),
|
||||
sourceKey: selectedSourceKey.value,
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCloudtentaclesBaseFields() {
|
||||
if (!cloudtentaclesForm.value.username.trim() || !cloudtentaclesForm.value.phone.trim()) {
|
||||
cloudtentaclesResultError.value = '请先填写 cloudtentacles 账号和手机号'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function ensureCloudtentaclesLoginFields() {
|
||||
if (
|
||||
!cloudtentaclesForm.value.username.trim() ||
|
||||
!cloudtentaclesForm.value.password.trim() ||
|
||||
!cloudtentaclesForm.value.phone.trim()
|
||||
) {
|
||||
cloudtentaclesResultError.value = '请先填写 cloudtentacles 账号、密码和手机号'
|
||||
return false
|
||||
}
|
||||
|
||||
if (!cloudtentaclesForm.value.code.trim()) {
|
||||
cloudtentaclesResultError.value = '请先填写短信验证码'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesSaveSource() {
|
||||
cloudtentaclesSaving.value = true
|
||||
cloudtentaclesResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await saveAdminCloudtentaclesSourceConfig(buildCloudtentaclesConfigPayload())
|
||||
cloudtentaclesFilePath.value = response.data.filePath
|
||||
cloudtentaclesSessionFilePath.value = response.data.sessionFilePath
|
||||
sources.value = response.data.sources
|
||||
sessions.value = response.data.sessions
|
||||
const session = sessions.value[selectedSourceKey.value]
|
||||
cloudtentaclesPersistedSession.value = session || null
|
||||
cloudtentaclesForm.value.token = session?.token || ''
|
||||
showSuccess('cloudtentacles 履约平台配置已保存')
|
||||
} catch (error) {
|
||||
cloudtentaclesResultError.value =
|
||||
error instanceof Error ? error.message : 'cloudtentacles 配置保存失败'
|
||||
showError(cloudtentaclesResultError.value)
|
||||
} finally {
|
||||
cloudtentaclesSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesSendSmsCode() {
|
||||
if (!ensureCloudtentaclesBaseFields()) {
|
||||
return
|
||||
}
|
||||
|
||||
cloudtentaclesSendingSms.value = true
|
||||
cloudtentaclesResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await sendAdminCloudtentaclesSmsCode(buildCloudtentaclesSourcePayload())
|
||||
cloudtentaclesSmsResult.value = response.data
|
||||
showSuccess('cloudtentacles 短信验证码已发送')
|
||||
} catch (error) {
|
||||
cloudtentaclesResultError.value =
|
||||
error instanceof Error ? error.message : 'cloudtentacles 发送短信验证码失败'
|
||||
showError(cloudtentaclesResultError.value)
|
||||
} finally {
|
||||
cloudtentaclesSendingSms.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesTestLogin() {
|
||||
if (!ensureCloudtentaclesLoginFields()) {
|
||||
return
|
||||
}
|
||||
|
||||
cloudtentaclesTesting.value = true
|
||||
cloudtentaclesResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await testAdminCloudtentaclesLogin(buildCloudtentaclesLoginPayload())
|
||||
cloudtentaclesLoginResult.value = response.data
|
||||
cloudtentaclesForm.value.token = response.data.token || cloudtentaclesForm.value.token
|
||||
cloudtentaclesPersistedSession.value = {
|
||||
token: response.data.token || '',
|
||||
tokenMasked: response.data.session.tokenMasked,
|
||||
baseUrl: response.data.baseUrl,
|
||||
username: response.data.username,
|
||||
phoneMasked: response.data.phoneMasked,
|
||||
loggedInAt: response.data.loggedInAt,
|
||||
deviceId: normalizeCloudtentaclesDeviceId(cloudtentaclesForm.value.deviceId),
|
||||
deviceType: normalizeCloudtentaclesDeviceType(cloudtentaclesForm.value.deviceType),
|
||||
hasToken: Boolean(response.data.token),
|
||||
}
|
||||
// Update sessions map
|
||||
sessions.value[selectedSourceKey.value] = cloudtentaclesPersistedSession.value
|
||||
showSuccess('cloudtentacles 登录测试成功')
|
||||
} catch (error) {
|
||||
cloudtentaclesResultError.value =
|
||||
error instanceof Error ? error.message : 'cloudtentacles 登录测试失败'
|
||||
showError(cloudtentaclesResultError.value)
|
||||
} finally {
|
||||
cloudtentaclesTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesValidateSession() {
|
||||
const token = cloudtentaclesForm.value.token.trim()
|
||||
if (!token) {
|
||||
cloudtentaclesResultError.value = '请先填写 token'
|
||||
return
|
||||
}
|
||||
|
||||
cloudtentaclesValidating.value = true
|
||||
cloudtentaclesResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await validateAdminCloudtentaclesSession({
|
||||
baseUrl: cloudtentaclesForm.value.baseUrl.trim() || 'https://123.207.217.176',
|
||||
token,
|
||||
deviceId: normalizeCloudtentaclesDeviceId(cloudtentaclesForm.value.deviceId),
|
||||
deviceType: normalizeCloudtentaclesDeviceType(cloudtentaclesForm.value.deviceType),
|
||||
sourceKey: selectedSourceKey.value,
|
||||
})
|
||||
cloudtentaclesValidateResult.value = response.data
|
||||
cloudtentaclesPersistedSession.value = {
|
||||
token,
|
||||
tokenMasked: response.data.session.tokenMasked,
|
||||
baseUrl: response.data.baseUrl,
|
||||
username:
|
||||
cloudtentaclesPersistedSession.value?.username ||
|
||||
cloudtentaclesForm.value.username.trim(),
|
||||
phoneMasked: cloudtentaclesPersistedSession.value?.phoneMasked || '',
|
||||
loggedInAt: response.data.loggedInAt,
|
||||
deviceId: normalizeCloudtentaclesDeviceId(cloudtentaclesForm.value.deviceId),
|
||||
deviceType: normalizeCloudtentaclesDeviceType(cloudtentaclesForm.value.deviceType),
|
||||
hasToken: Boolean(token),
|
||||
}
|
||||
// Update sessions map
|
||||
sessions.value[selectedSourceKey.value] = cloudtentaclesPersistedSession.value
|
||||
showSuccess('cloudtentacles 会话校验成功')
|
||||
} catch (error) {
|
||||
cloudtentaclesResultError.value =
|
||||
error instanceof Error ? error.message : 'cloudtentacles 会话校验失败'
|
||||
showError(cloudtentaclesResultError.value)
|
||||
} finally {
|
||||
cloudtentaclesValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cloudtentaclesFilePath,
|
||||
cloudtentaclesSessionFilePath,
|
||||
cloudtentaclesForm,
|
||||
cloudtentaclesSaving,
|
||||
cloudtentaclesSendingSms,
|
||||
cloudtentaclesTesting,
|
||||
cloudtentaclesValidating,
|
||||
cloudtentaclesResultError,
|
||||
cloudtentaclesSmsResult,
|
||||
cloudtentaclesLoginResult,
|
||||
cloudtentaclesValidateResult,
|
||||
cloudtentaclesDebugLoading: debugActions.cloudtentaclesDebugLoading,
|
||||
cloudtentaclesDebugResult: debugActions.cloudtentaclesDebugResult,
|
||||
cloudtentaclesPersistedSession,
|
||||
cloudtentaclesStats,
|
||||
sources,
|
||||
sessions,
|
||||
selectedSourceKey,
|
||||
sourceOptions,
|
||||
currentSource,
|
||||
currentSession,
|
||||
currentSourceEnabled,
|
||||
hydrateCloudtentaclesConfig,
|
||||
switchSource,
|
||||
addSource,
|
||||
removeSource,
|
||||
handleCloudtentaclesSaveSource,
|
||||
handleCloudtentaclesSendSmsCode,
|
||||
handleCloudtentaclesTestLogin,
|
||||
handleCloudtentaclesValidateSession,
|
||||
handleCloudtentaclesFetchAsset: debugActions.handleCloudtentaclesFetchAsset,
|
||||
handleCloudtentaclesFetchCategories: debugActions.handleCloudtentaclesFetchCategories,
|
||||
handleCloudtentaclesFetchSkuList: debugActions.handleCloudtentaclesFetchSkuList,
|
||||
handleCloudtentaclesBuySku: debugActions.handleCloudtentaclesBuySku,
|
||||
handleCloudtentaclesUseSku: debugActions.handleCloudtentaclesUseSku,
|
||||
handleCloudtentaclesFetchKnapsack: debugActions.handleCloudtentaclesFetchKnapsack,
|
||||
handleCloudtentaclesFetchVnList: debugActions.handleCloudtentaclesFetchVnList,
|
||||
handleCloudtentaclesAppointVn: debugActions.handleCloudtentaclesAppointVn,
|
||||
handleCloudtentaclesGenerateVnLoginCode: debugActions.handleCloudtentaclesGenerateVnLoginCode,
|
||||
handleCloudtentaclesFetchVnCode: debugActions.handleCloudtentaclesFetchVnCode,
|
||||
handleCloudtentaclesVerifyVnCode: debugActions.handleCloudtentaclesVerifyVnCode,
|
||||
handleCloudtentaclesFetchBindUrl: debugActions.handleCloudtentaclesFetchBindUrl,
|
||||
handleCloudtentaclesBackVn: debugActions.handleCloudtentaclesBackVn,
|
||||
handleCloudtentaclesRunFullFlow: debugActions.handleCloudtentaclesRunFullFlow,
|
||||
}
|
||||
}
|
||||
-328
@@ -1,328 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
consumeAdminKuaishouEticket,
|
||||
queryAdminKuaishouEticketDetail,
|
||||
queryAdminKuaishouEticketShopInfo,
|
||||
saveAdminKuaishouEticketSourceConfig,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminKuaishouEticketConsumeResult,
|
||||
AdminKuaishouEticketDetailResult,
|
||||
AdminKuaishouEticketShopInfoResult,
|
||||
AdminKuaishouEticketSourceConfig,
|
||||
} from '@/types/admin'
|
||||
import type { EditableKuaishouEticketShop } from './types'
|
||||
import { useKuaishouEticketShopManager } from './useKuaishouEticketShopManager'
|
||||
|
||||
type KuaishouEticketForm = {
|
||||
baseUrl: string
|
||||
selectedShopId: string
|
||||
queryTicketCode: string
|
||||
eTicketId: string
|
||||
oid: string
|
||||
formToken: string
|
||||
num: number
|
||||
storeId: string
|
||||
}
|
||||
|
||||
export function useAdminKuaishouEticketPlatform() {
|
||||
const shopManager = useKuaishouEticketShopManager()
|
||||
|
||||
const kuaishouEticketFilePath = ref('')
|
||||
const kuaishouEticketForm = ref<KuaishouEticketForm>({
|
||||
baseUrl: 'https://s.kwaixiaodian.com',
|
||||
selectedShopId: '',
|
||||
queryTicketCode: '',
|
||||
eTicketId: '',
|
||||
oid: '',
|
||||
formToken: '',
|
||||
num: 1,
|
||||
storeId: '0',
|
||||
})
|
||||
const kuaishouEticketSaving = ref(false)
|
||||
const kuaishouEticketResolvingShopId = ref('')
|
||||
const kuaishouEticketDetailing = ref(false)
|
||||
const kuaishouEticketConsuming = ref(false)
|
||||
const kuaishouEticketResultError = ref('')
|
||||
const kuaishouEticketShopInfoResult = ref<AdminKuaishouEticketShopInfoResult | null>(null)
|
||||
const kuaishouEticketDetailResult = ref<AdminKuaishouEticketDetailResult | null>(null)
|
||||
const kuaishouEticketConsumeResult = ref<AdminKuaishouEticketConsumeResult | null>(null)
|
||||
|
||||
const activeKuaishouEticketShop = computed(
|
||||
() =>
|
||||
shopManager.kuaishouEticketShops.value.find(
|
||||
(item) => item.id === kuaishouEticketForm.value.selectedShopId,
|
||||
) || null,
|
||||
)
|
||||
|
||||
const kuaishouEticketStats = computed(() => ({
|
||||
configuredShopCount: shopManager.kuaishouEticketShops.value.length,
|
||||
cookieReadyCount: shopManager.kuaishouEticketShops.value.filter((item) => item.cookie.trim()).length,
|
||||
selectedShopReady: Boolean(activeKuaishouEticketShop.value?.cookie.trim()),
|
||||
detailReady: Boolean(kuaishouEticketDetailResult.value?.detail),
|
||||
consumed: Boolean(kuaishouEticketConsumeResult.value?.consumed),
|
||||
}))
|
||||
|
||||
function hydrateKuaishouEticketConfig(data: {
|
||||
filePath: string
|
||||
source: AdminKuaishouEticketSourceConfig
|
||||
}) {
|
||||
kuaishouEticketFilePath.value = data.filePath
|
||||
shopManager.setShopsFromConfig(data.source.shops)
|
||||
kuaishouEticketForm.value = {
|
||||
baseUrl: data.source.baseUrl || 'https://s.kwaixiaodian.com',
|
||||
selectedShopId: '',
|
||||
queryTicketCode: '',
|
||||
eTicketId: '',
|
||||
oid: '',
|
||||
formToken: '',
|
||||
num: 1,
|
||||
storeId: '0',
|
||||
}
|
||||
kuaishouEticketForm.value.selectedShopId = shopManager.kuaishouEticketShops.value[0]?.id || ''
|
||||
}
|
||||
|
||||
function buildKuaishouEticketConfigPayload(): AdminKuaishouEticketSourceConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
baseUrl: kuaishouEticketForm.value.baseUrl.trim() || 'https://s.kwaixiaodian.com',
|
||||
shops: shopManager.kuaishouEticketShops.value.map((item) => ({
|
||||
shopId: item.shopId.trim(),
|
||||
kshopName: item.kshopName.trim(),
|
||||
cookie: item.cookie.trim(),
|
||||
cookieMasked: '',
|
||||
hasCookie: Boolean(item.cookie.trim()),
|
||||
userAvatar: item.userAvatar.trim(),
|
||||
enabled: item.enabled,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function addKuaishouEticketShop() {
|
||||
const { newSelectedId } = shopManager.addKuaishouEticketShop()
|
||||
setKuaishouEticketDebugShop(newSelectedId)
|
||||
}
|
||||
|
||||
function removeKuaishouEticketShop(id: string) {
|
||||
kuaishouEticketForm.value.selectedShopId = shopManager.removeKuaishouEticketShop(
|
||||
id,
|
||||
kuaishouEticketForm.value.selectedShopId,
|
||||
)
|
||||
}
|
||||
|
||||
function setKuaishouEticketDebugShop(id: string) {
|
||||
kuaishouEticketForm.value.selectedShopId = id
|
||||
}
|
||||
|
||||
function ensureSelectedKuaishouEticketShop() {
|
||||
const selected = activeKuaishouEticketShop.value
|
||||
|
||||
if (!selected) {
|
||||
kuaishouEticketResultError.value = '请先新增并选择一个店铺配置'
|
||||
return null
|
||||
}
|
||||
|
||||
if (!selected.cookie.trim()) {
|
||||
kuaishouEticketResultError.value = '当前店铺缺少 Cookie,请先补全后再操作'
|
||||
return null
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
function ensureKuaishouEticketQueryFields() {
|
||||
const selected = ensureSelectedKuaishouEticketShop()
|
||||
if (!selected) return false
|
||||
|
||||
if (!kuaishouEticketForm.value.queryTicketCode.trim()) {
|
||||
kuaishouEticketResultError.value = '请先填写查询券码'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function ensureKuaishouEticketConsumeFields() {
|
||||
const selected = ensureSelectedKuaishouEticketShop()
|
||||
if (!selected) return false
|
||||
|
||||
if (!kuaishouEticketDetailResult.value?.detail) {
|
||||
kuaishouEticketResultError.value =
|
||||
'请先查询核销信息,系统会自动带出 eTicketId、oid 和 formToken'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function applyKuaishouEticketDetailToForm(result: AdminKuaishouEticketDetailResult | null) {
|
||||
if (!result?.detail) {
|
||||
return
|
||||
}
|
||||
|
||||
kuaishouEticketForm.value.eTicketId =
|
||||
result.detail.eTicketId || kuaishouEticketForm.value.eTicketId
|
||||
kuaishouEticketForm.value.oid = result.detail.oid || kuaishouEticketForm.value.oid
|
||||
kuaishouEticketForm.value.formToken =
|
||||
result.detail.formToken || kuaishouEticketForm.value.formToken
|
||||
|
||||
const leftCount = Number(result.detail.leftCount || 0)
|
||||
if (leftCount > 0) {
|
||||
kuaishouEticketForm.value.num = leftCount
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKuaishouEticketResolveShopInfo(shop: EditableKuaishouEticketShop) {
|
||||
if (!shop.cookie.trim()) {
|
||||
kuaishouEticketResultError.value = '请先为该店铺粘贴 Cookie'
|
||||
showError(kuaishouEticketResultError.value)
|
||||
return
|
||||
}
|
||||
|
||||
kuaishouEticketResolvingShopId.value = shop.id
|
||||
kuaishouEticketResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await queryAdminKuaishouEticketShopInfo({
|
||||
baseUrl: kuaishouEticketForm.value.baseUrl.trim() || 'https://s.kwaixiaodian.com',
|
||||
shopId: shop.shopId.trim(),
|
||||
cookie: shop.cookie.trim(),
|
||||
})
|
||||
kuaishouEticketShopInfoResult.value = response.data
|
||||
shop.shopId = response.data.shop.shopId || shop.shopId
|
||||
shop.kshopName = response.data.shop.kshopName || shop.kshopName
|
||||
shop.userAvatar = response.data.shop.userAvatar || shop.userAvatar
|
||||
if (!kuaishouEticketForm.value.selectedShopId) {
|
||||
setKuaishouEticketDebugShop(shop.id)
|
||||
}
|
||||
showSuccess(
|
||||
`已识别店铺:${response.data.shop.kshopName || response.data.shop.shopId || '未知店铺'}`,
|
||||
)
|
||||
} catch (error) {
|
||||
kuaishouEticketResultError.value =
|
||||
error instanceof Error ? error.message : '读取快手小店店铺信息失败'
|
||||
showError(kuaishouEticketResultError.value)
|
||||
} finally {
|
||||
kuaishouEticketResolvingShopId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKuaishouEticketSaveSource() {
|
||||
kuaishouEticketSaving.value = true
|
||||
kuaishouEticketResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await saveAdminKuaishouEticketSourceConfig(
|
||||
buildKuaishouEticketConfigPayload(),
|
||||
)
|
||||
kuaishouEticketFilePath.value = response.data.filePath
|
||||
kuaishouEticketForm.value.baseUrl =
|
||||
response.data.source.baseUrl || 'https://s.kwaixiaodian.com'
|
||||
const previousSelectedShopId = activeKuaishouEticketShop.value?.shopId || ''
|
||||
shopManager.setShopsFromConfig(response.data.source.shops)
|
||||
kuaishouEticketForm.value.selectedShopId =
|
||||
shopManager.findShopByShopId(previousSelectedShopId)?.id ||
|
||||
shopManager.kuaishouEticketShops.value[0]?.id ||
|
||||
''
|
||||
showSuccess('快手小店核销配置已保存')
|
||||
} catch (error) {
|
||||
kuaishouEticketResultError.value =
|
||||
error instanceof Error ? error.message : '快手小店核销配置保存失败'
|
||||
showError(kuaishouEticketResultError.value)
|
||||
} finally {
|
||||
kuaishouEticketSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKuaishouEticketQueryDetail() {
|
||||
if (!ensureKuaishouEticketQueryFields()) {
|
||||
return
|
||||
}
|
||||
|
||||
const selected = activeKuaishouEticketShop.value
|
||||
kuaishouEticketDetailing.value = true
|
||||
kuaishouEticketResultError.value = ''
|
||||
kuaishouEticketConsumeResult.value = null
|
||||
|
||||
try {
|
||||
const response = await queryAdminKuaishouEticketDetail({
|
||||
baseUrl: kuaishouEticketForm.value.baseUrl.trim() || 'https://s.kwaixiaodian.com',
|
||||
shopId: selected?.shopId.trim(),
|
||||
cookie: selected?.cookie.trim(),
|
||||
eTicketId: kuaishouEticketForm.value.queryTicketCode.trim(),
|
||||
})
|
||||
kuaishouEticketDetailResult.value = response.data
|
||||
applyKuaishouEticketDetailToForm(response.data)
|
||||
showSuccess(
|
||||
response.data.ok ? '核销信息查询成功' : response.data.errorMessage || '核销信息已返回',
|
||||
)
|
||||
} catch (error) {
|
||||
kuaishouEticketResultError.value =
|
||||
error instanceof Error ? error.message : '查询快手小店核销信息失败'
|
||||
showError(kuaishouEticketResultError.value)
|
||||
} finally {
|
||||
kuaishouEticketDetailing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKuaishouEticketConsume() {
|
||||
if (!ensureKuaishouEticketConsumeFields()) {
|
||||
return
|
||||
}
|
||||
|
||||
const selected = activeKuaishouEticketShop.value
|
||||
kuaishouEticketConsuming.value = true
|
||||
kuaishouEticketResultError.value = ''
|
||||
|
||||
try {
|
||||
const detail = kuaishouEticketDetailResult.value?.detail
|
||||
const response = await consumeAdminKuaishouEticket({
|
||||
baseUrl: kuaishouEticketForm.value.baseUrl.trim() || 'https://s.kwaixiaodian.com',
|
||||
shopId: selected?.shopId.trim(),
|
||||
cookie: selected?.cookie.trim(),
|
||||
eTicketId: String(detail?.eTicketId || kuaishouEticketForm.value.eTicketId || '').trim(),
|
||||
oid: String(detail?.oid || kuaishouEticketForm.value.oid || '').trim(),
|
||||
formToken: String(detail?.formToken || kuaishouEticketForm.value.formToken || '').trim(),
|
||||
num: Number(kuaishouEticketForm.value.num || 1),
|
||||
storeId: kuaishouEticketForm.value.storeId.trim() || '0',
|
||||
})
|
||||
kuaishouEticketConsumeResult.value = response.data
|
||||
showSuccess(
|
||||
response.data.ok ? '快手小店核销成功' : response.data.errorMessage || '核销请求已执行',
|
||||
)
|
||||
} catch (error) {
|
||||
kuaishouEticketResultError.value =
|
||||
error instanceof Error ? error.message : '执行快手小店核销失败'
|
||||
showError(kuaishouEticketResultError.value)
|
||||
} finally {
|
||||
kuaishouEticketConsuming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kuaishouEticketFilePath,
|
||||
kuaishouEticketShops: shopManager.kuaishouEticketShops,
|
||||
kuaishouEticketForm,
|
||||
kuaishouEticketSaving,
|
||||
kuaishouEticketResolvingShopId,
|
||||
kuaishouEticketDetailing,
|
||||
kuaishouEticketConsuming,
|
||||
kuaishouEticketResultError,
|
||||
kuaishouEticketShopInfoResult,
|
||||
kuaishouEticketDetailResult,
|
||||
kuaishouEticketConsumeResult,
|
||||
activeKuaishouEticketShop,
|
||||
kuaishouEticketStats,
|
||||
hydrateKuaishouEticketConfig,
|
||||
addKuaishouEticketShop,
|
||||
removeKuaishouEticketShop,
|
||||
setKuaishouEticketDebugShop,
|
||||
describeKuaishouEticketShop: shopManager.describeKuaishouEticketShop,
|
||||
getKuaishouEticketShopInitial: shopManager.getKuaishouEticketShopInitial,
|
||||
handleKuaishouEticketResolveShopInfo,
|
||||
handleKuaishouEticketSaveSource,
|
||||
handleKuaishouEticketQueryDetail,
|
||||
handleKuaishouEticketConsume,
|
||||
}
|
||||
}
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
failAdminNinetyoneOrder,
|
||||
fetchAdminNinetyoneOrders,
|
||||
retryAdminNinetyoneOrder,
|
||||
} from '@/services/admin'
|
||||
import type { AdminNinetyoneOrderItem, AdminNinetyoneOrderListResult } from '@/types/admin'
|
||||
|
||||
export function useAdminNinetyonePlatform() {
|
||||
const ninetyoneLoading = ref(false)
|
||||
const ninetyoneActionLoadingId = ref<number | null>(null)
|
||||
const ninetyoneResultError = ref('')
|
||||
const ninetyoneStatus = ref<'pending_config' | 'all' | 'manual_failed'>('pending_config')
|
||||
const ninetyoneOrders = ref<AdminNinetyoneOrderListResult>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
items: [],
|
||||
})
|
||||
|
||||
const ninetyoneStats = computed(() => {
|
||||
const pendingCount = ninetyoneOrders.value.items.filter(
|
||||
(item) => item.orderStatus === 'pending_config',
|
||||
).length
|
||||
const failedCount = ninetyoneOrders.value.items.filter(
|
||||
(item) => item.orderStatus === 'manual_failed',
|
||||
).length
|
||||
const taskReadyCount = ninetyoneOrders.value.items.filter((item) => item.taskCount > 0).length
|
||||
|
||||
return {
|
||||
total: ninetyoneOrders.value.total,
|
||||
currentCount: ninetyoneOrders.value.items.length,
|
||||
pendingCount,
|
||||
failedCount,
|
||||
taskReadyCount,
|
||||
}
|
||||
})
|
||||
|
||||
async function loadNinetyoneOrders(page = 1) {
|
||||
ninetyoneLoading.value = true
|
||||
ninetyoneResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetchAdminNinetyoneOrders({
|
||||
page,
|
||||
pageSize: ninetyoneOrders.value.pageSize || 20,
|
||||
status: ninetyoneStatus.value,
|
||||
})
|
||||
ninetyoneOrders.value = response.data
|
||||
} catch (error) {
|
||||
ninetyoneResultError.value = error instanceof Error ? error.message : '91卡券订单读取失败'
|
||||
showError(ninetyoneResultError.value)
|
||||
} finally {
|
||||
ninetyoneLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNinetyoneStatusChange(status: 'pending_config' | 'all' | 'manual_failed') {
|
||||
ninetyoneStatus.value = status
|
||||
await loadNinetyoneOrders(1)
|
||||
}
|
||||
|
||||
async function handleNinetyoneRetryOrder(item: AdminNinetyoneOrderItem) {
|
||||
ninetyoneActionLoadingId.value = item.orderId
|
||||
ninetyoneResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await retryAdminNinetyoneOrder(item.orderId)
|
||||
showSuccess(`91卡券订单已重试,生成任务 ${response.data.taskCount} 个`)
|
||||
await loadNinetyoneOrders(ninetyoneOrders.value.page || 1)
|
||||
} catch (error) {
|
||||
ninetyoneResultError.value = error instanceof Error ? error.message : '91卡券订单重试失败'
|
||||
showError(ninetyoneResultError.value)
|
||||
} finally {
|
||||
ninetyoneActionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNinetyoneFailOrder(item: AdminNinetyoneOrderItem) {
|
||||
try {
|
||||
await showConfirm(
|
||||
`确认把 91卡券订单 ${item.orderNo} 标记为无法履约吗?查询接口会返回失败状态。`,
|
||||
'确认标记失败',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '标记失败',
|
||||
cancelButtonText: '取消',
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
ninetyoneActionLoadingId.value = item.orderId
|
||||
ninetyoneResultError.value = ''
|
||||
|
||||
try {
|
||||
await failAdminNinetyoneOrder(item.orderId, {
|
||||
reason: '商家后台手动标记无法履约',
|
||||
})
|
||||
showSuccess('91卡券订单已标记失败')
|
||||
await loadNinetyoneOrders(ninetyoneOrders.value.page || 1)
|
||||
} catch (error) {
|
||||
ninetyoneResultError.value = error instanceof Error ? error.message : '91卡券订单标记失败失败'
|
||||
showError(ninetyoneResultError.value)
|
||||
} finally {
|
||||
ninetyoneActionLoadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ninetyoneLoading,
|
||||
ninetyoneActionLoadingId,
|
||||
ninetyoneResultError,
|
||||
ninetyoneStatus,
|
||||
ninetyoneOrders,
|
||||
ninetyoneStats,
|
||||
loadNinetyoneOrders,
|
||||
handleNinetyoneStatusChange,
|
||||
handleNinetyoneRetryOrder,
|
||||
handleNinetyoneFailOrder,
|
||||
}
|
||||
}
|
||||
-384
@@ -1,384 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
runAdminScheduledJob,
|
||||
saveAdminNotificationConfig,
|
||||
saveAdminScheduledJobsConfig,
|
||||
testAdminNotification,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminCloudtentaclesMonitorAccount,
|
||||
AdminNotificationConfig,
|
||||
AdminNotificationTestResult,
|
||||
AdminScheduledJobRuntimeState,
|
||||
AdminScheduledJobsConfig,
|
||||
AdminScheduledJobsResponse,
|
||||
} from '@/types/admin'
|
||||
|
||||
import type {
|
||||
EditableNotificationRecipient,
|
||||
EditableScheduledJob,
|
||||
EditableWpushRecipient,
|
||||
} from './types'
|
||||
|
||||
export function useAdminNotificationPlatform() {
|
||||
const notificationFilePath = ref('')
|
||||
const notificationForm = ref({
|
||||
enabled: true,
|
||||
barkEnabled: true,
|
||||
barkServerUrl: 'https://api.day.app',
|
||||
wpushEnabled: true,
|
||||
testTitle: '订单系统测试通知',
|
||||
testBody: '这是一条内部通知测试。',
|
||||
testUrl: '',
|
||||
})
|
||||
const notificationRecipients = ref<EditableNotificationRecipient[]>([])
|
||||
const wpushRecipients = ref<EditableWpushRecipient[]>([])
|
||||
const notificationSaving = ref(false)
|
||||
const notificationTesting = ref(false)
|
||||
const notificationResultError = ref('')
|
||||
const notificationTestResult = ref<AdminNotificationTestResult | null>(null)
|
||||
const scheduledJobsFilePath = ref('')
|
||||
const scheduledJobsForm = ref({
|
||||
enabled: true,
|
||||
})
|
||||
const scheduledJobs = ref<EditableScheduledJob[]>([])
|
||||
const scheduledJobRuntime = ref<AdminScheduledJobRuntimeState[]>([])
|
||||
const cloudtentaclesMonitorAccounts = ref<AdminCloudtentaclesMonitorAccount[]>([])
|
||||
const scheduledJobsSaving = ref(false)
|
||||
const scheduledJobRunningId = ref('')
|
||||
|
||||
const notificationStats = computed(() => {
|
||||
const enabledBarkRecipients = notificationRecipients.value.filter(
|
||||
(item) => item.enabled && item.deviceKey.trim(),
|
||||
)
|
||||
const enabledWpushRecipients = wpushRecipients.value.filter(
|
||||
(item) => item.enabled && item.apiKey.trim(),
|
||||
)
|
||||
|
||||
return {
|
||||
barkConfiguredRecipientCount: notificationRecipients.value.length,
|
||||
barkEnabledRecipientCount: enabledBarkRecipients.length,
|
||||
wpushConfiguredRecipientCount: wpushRecipients.value.length,
|
||||
wpushEnabledRecipientCount: enabledWpushRecipients.length,
|
||||
barkReady:
|
||||
notificationForm.value.enabled &&
|
||||
notificationForm.value.barkEnabled &&
|
||||
enabledBarkRecipients.length > 0,
|
||||
wpushReady:
|
||||
notificationForm.value.enabled &&
|
||||
notificationForm.value.wpushEnabled &&
|
||||
enabledWpushRecipients.length > 0,
|
||||
ready:
|
||||
(
|
||||
notificationForm.value.enabled &&
|
||||
notificationForm.value.barkEnabled &&
|
||||
enabledBarkRecipients.length > 0
|
||||
) ||
|
||||
(
|
||||
notificationForm.value.enabled &&
|
||||
notificationForm.value.wpushEnabled &&
|
||||
enabledWpushRecipients.length > 0
|
||||
),
|
||||
lastSuccessCount: notificationTestResult.value?.successCount || 0,
|
||||
lastFailedCount: notificationTestResult.value?.failedCount || 0,
|
||||
scheduledJobEnabledCount: scheduledJobs.value.filter((item) => item.enabled).length,
|
||||
}
|
||||
})
|
||||
|
||||
function hydrateNotificationConfig(data: { filePath: string; source: AdminNotificationConfig }) {
|
||||
notificationFilePath.value = data.filePath
|
||||
notificationForm.value.enabled = data.source.enabled !== false
|
||||
notificationForm.value.barkEnabled = data.source.channels.bark.enabled !== false
|
||||
notificationForm.value.barkServerUrl =
|
||||
data.source.channels.bark.serverUrl || 'https://api.day.app'
|
||||
notificationForm.value.wpushEnabled = data.source.channels.wpush.enabled !== false
|
||||
notificationRecipients.value = data.source.channels.bark.recipients.map((item) => ({
|
||||
id: item.id || crypto.randomUUID(),
|
||||
name: item.name,
|
||||
deviceKey: item.deviceKey,
|
||||
enabled: item.enabled !== false,
|
||||
}))
|
||||
wpushRecipients.value = data.source.channels.wpush.recipients.map((item) => ({
|
||||
id: item.id || crypto.randomUUID(),
|
||||
name: item.name,
|
||||
apiKey: item.apiKey,
|
||||
enabled: item.enabled !== false,
|
||||
}))
|
||||
}
|
||||
|
||||
function hydrateScheduledJobsConfig(data: AdminScheduledJobsResponse) {
|
||||
scheduledJobsFilePath.value = data.filePath
|
||||
scheduledJobsForm.value.enabled = data.source.enabled !== false
|
||||
cloudtentaclesMonitorAccounts.value = data.cloudtentaclesAccounts || []
|
||||
scheduledJobs.value = data.source.jobs.map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
enabled: item.enabled === true,
|
||||
intervalSeconds: Number(item.intervalSeconds || 300),
|
||||
intervalSecondsAmount: resolveTimeValue(Number(item.intervalSeconds || 300)).amount,
|
||||
intervalSecondsUnit: resolveTimeValue(Number(item.intervalSeconds || 300)).unit,
|
||||
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
||||
cooldownSecondsAmount: resolveTimeValue(Number(item.cooldownSeconds || 1800)).amount,
|
||||
cooldownSecondsUnit: resolveTimeValue(Number(item.cooldownSeconds || 1800)).unit,
|
||||
assetThreshold: normalizeAssetThreshold(item.config?.assetThreshold, 500),
|
||||
accounts: mergeScheduledJobAccounts(
|
||||
item.config?.accounts || [],
|
||||
data.cloudtentaclesAccounts || [],
|
||||
normalizeAssetThreshold(item.config?.assetThreshold, 500),
|
||||
),
|
||||
}))
|
||||
scheduledJobRuntime.value = data.runtime || []
|
||||
}
|
||||
|
||||
function addNotificationRecipient(channel: 'bark' | 'wpush') {
|
||||
if (channel === 'wpush') {
|
||||
wpushRecipients.value.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
apiKey: '',
|
||||
enabled: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
notificationRecipients.value.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
name: '',
|
||||
deviceKey: '',
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
function removeNotificationRecipient(channel: 'bark' | 'wpush', id: string) {
|
||||
if (channel === 'wpush') {
|
||||
wpushRecipients.value = wpushRecipients.value.filter((item) => item.id !== id)
|
||||
return
|
||||
}
|
||||
|
||||
notificationRecipients.value = notificationRecipients.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
function buildNotificationConfigPayload(): AdminNotificationConfig {
|
||||
return {
|
||||
enabled: notificationForm.value.enabled,
|
||||
channels: {
|
||||
bark: {
|
||||
enabled: notificationForm.value.barkEnabled,
|
||||
serverUrl: notificationForm.value.barkServerUrl.trim() || 'https://api.day.app',
|
||||
recipients: notificationRecipients.value.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name.trim(),
|
||||
deviceKey: item.deviceKey.trim(),
|
||||
deviceKeyMasked: '',
|
||||
enabled: item.enabled,
|
||||
})),
|
||||
},
|
||||
wpush: {
|
||||
enabled: notificationForm.value.wpushEnabled,
|
||||
recipients: wpushRecipients.value.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name.trim(),
|
||||
apiKey: item.apiKey.trim(),
|
||||
apiKeyMasked: '',
|
||||
enabled: item.enabled,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function buildScheduledJobsConfigPayload(): AdminScheduledJobsConfig {
|
||||
return {
|
||||
enabled: scheduledJobsForm.value.enabled,
|
||||
jobs: scheduledJobs.value.map((item) => ({
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
enabled: item.enabled,
|
||||
intervalSeconds: Number(item.intervalSeconds || 300),
|
||||
cooldownSeconds: Number(item.cooldownSeconds || 1800),
|
||||
config: {
|
||||
assetThreshold: normalizeAssetThreshold(item.assetThreshold, 500),
|
||||
accounts: item.accounts.map((account) => ({
|
||||
sourceKey: account.sourceKey,
|
||||
label: account.label.trim(),
|
||||
enabled: account.enabled,
|
||||
assetThreshold: normalizeAssetThreshold(account.assetThreshold, 0),
|
||||
})),
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationSaveConfig() {
|
||||
notificationSaving.value = true
|
||||
notificationResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await saveAdminNotificationConfig(buildNotificationConfigPayload())
|
||||
notificationFilePath.value = response.data.filePath
|
||||
hydrateNotificationConfig(response.data)
|
||||
showSuccess('内部通知配置已保存')
|
||||
} catch (error) {
|
||||
notificationResultError.value =
|
||||
error instanceof Error ? error.message : '内部通知配置保存失败'
|
||||
showError(notificationResultError.value)
|
||||
} finally {
|
||||
notificationSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNotificationTest() {
|
||||
notificationTesting.value = true
|
||||
notificationResultError.value = ''
|
||||
notificationTestResult.value = null
|
||||
|
||||
try {
|
||||
const saved = await saveAdminNotificationConfig(buildNotificationConfigPayload())
|
||||
notificationFilePath.value = saved.data.filePath
|
||||
hydrateNotificationConfig(saved.data)
|
||||
|
||||
const response = await testAdminNotification({
|
||||
title: notificationForm.value.testTitle.trim(),
|
||||
body: notificationForm.value.testBody.trim(),
|
||||
url: notificationForm.value.testUrl.trim(),
|
||||
})
|
||||
notificationTestResult.value = response.data
|
||||
if (response.data.successCount > 0) {
|
||||
showSuccess(`内部通知测试完成,成功 ${response.data.successCount} 个`)
|
||||
} else {
|
||||
showError('内部通知测试未成功发送,请检查通知通道配置')
|
||||
}
|
||||
} catch (error) {
|
||||
notificationResultError.value = error instanceof Error ? error.message : '内部通知测试失败'
|
||||
showError(notificationResultError.value)
|
||||
} finally {
|
||||
notificationTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScheduledJobsSaveConfig() {
|
||||
scheduledJobsSaving.value = true
|
||||
notificationResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await saveAdminScheduledJobsConfig(buildScheduledJobsConfigPayload())
|
||||
hydrateScheduledJobsConfig(response.data)
|
||||
showSuccess('监控任务配置已保存')
|
||||
} catch (error) {
|
||||
notificationResultError.value =
|
||||
error instanceof Error ? error.message : '监控任务配置保存失败'
|
||||
showError(notificationResultError.value)
|
||||
} finally {
|
||||
scheduledJobsSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScheduledJobRunNow(jobId: string) {
|
||||
scheduledJobRunningId.value = jobId
|
||||
notificationResultError.value = ''
|
||||
|
||||
try {
|
||||
const saved = await saveAdminScheduledJobsConfig(buildScheduledJobsConfigPayload())
|
||||
hydrateScheduledJobsConfig(saved.data)
|
||||
const response = await runAdminScheduledJob(jobId)
|
||||
scheduledJobRuntime.value = response.data.runtime || []
|
||||
const message = String(response.data.result?.message || '监控任务已执行')
|
||||
showSuccess(message)
|
||||
} catch (error) {
|
||||
notificationResultError.value = error instanceof Error ? error.message : '监控任务执行失败'
|
||||
showError(notificationResultError.value)
|
||||
} finally {
|
||||
scheduledJobRunningId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function getScheduledJobRuntime(jobId: string) {
|
||||
return scheduledJobRuntime.value.find((item) => item.id === jobId) || null
|
||||
}
|
||||
|
||||
function mergeScheduledJobAccounts(
|
||||
configuredAccounts: EditableScheduledJob['accounts'],
|
||||
monitorAccounts: AdminCloudtentaclesMonitorAccount[],
|
||||
defaultAssetThreshold: number,
|
||||
) {
|
||||
const configuredMap = new Map(
|
||||
configuredAccounts.map((item) => [String(item.sourceKey || '').trim(), item]),
|
||||
)
|
||||
const merged = monitorAccounts
|
||||
.filter((item) => String(item.sourceKey || '').trim())
|
||||
.map((item) => {
|
||||
const sourceKey = String(item.sourceKey || '').trim()
|
||||
const configured = configuredMap.get(sourceKey)
|
||||
return {
|
||||
sourceKey,
|
||||
label: String(configured?.label || item.label || sourceKey).trim(),
|
||||
enabled: configured ? configured.enabled !== false : item.enabled !== false,
|
||||
assetThreshold: normalizeAssetThreshold(configured?.assetThreshold, defaultAssetThreshold),
|
||||
}
|
||||
})
|
||||
const mergedKeys = new Set(merged.map((item) => item.sourceKey))
|
||||
|
||||
for (const configured of configuredAccounts) {
|
||||
const sourceKey = String(configured.sourceKey || '').trim()
|
||||
if (!sourceKey || mergedKeys.has(sourceKey)) {
|
||||
continue
|
||||
}
|
||||
merged.push({
|
||||
sourceKey,
|
||||
label: String(configured.label || sourceKey).trim(),
|
||||
enabled: configured.enabled !== false,
|
||||
assetThreshold: normalizeAssetThreshold(configured.assetThreshold, defaultAssetThreshold),
|
||||
})
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function normalizeAssetThreshold(value: unknown, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function resolveTimeValue(seconds: number) {
|
||||
const normalized = Math.max(0, Number(seconds || 0))
|
||||
if (normalized >= 3600 && normalized % 3600 === 0) {
|
||||
return { amount: normalized / 3600, unit: 3600 }
|
||||
}
|
||||
|
||||
if (normalized >= 60 && normalized % 60 === 0) {
|
||||
return { amount: normalized / 60, unit: 60 }
|
||||
}
|
||||
|
||||
return { amount: normalized || 1, unit: 1 }
|
||||
}
|
||||
|
||||
return {
|
||||
notificationFilePath,
|
||||
notificationForm,
|
||||
notificationRecipients,
|
||||
wpushRecipients,
|
||||
notificationSaving,
|
||||
notificationTesting,
|
||||
notificationResultError,
|
||||
notificationTestResult,
|
||||
scheduledJobsFilePath,
|
||||
scheduledJobsForm,
|
||||
scheduledJobs,
|
||||
scheduledJobRuntime,
|
||||
cloudtentaclesMonitorAccounts,
|
||||
scheduledJobsSaving,
|
||||
scheduledJobRunningId,
|
||||
notificationStats,
|
||||
hydrateNotificationConfig,
|
||||
hydrateScheduledJobsConfig,
|
||||
addNotificationRecipient,
|
||||
removeNotificationRecipient,
|
||||
handleNotificationSaveConfig,
|
||||
handleNotificationTest,
|
||||
handleScheduledJobsSaveConfig,
|
||||
handleScheduledJobRunNow,
|
||||
getScheduledJobRuntime,
|
||||
}
|
||||
}
|
||||
-261
@@ -1,261 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { showError, showSuccess } from '@/lib/feedback'
|
||||
import {
|
||||
appointAdminCloudtentaclesVn,
|
||||
backAdminCloudtentaclesVn,
|
||||
buyAdminCloudtentaclesSku,
|
||||
fetchAdminCloudtentaclesAsset,
|
||||
fetchAdminCloudtentaclesBindUrl,
|
||||
fetchAdminCloudtentaclesCategories,
|
||||
fetchAdminCloudtentaclesKnapsack,
|
||||
fetchAdminCloudtentaclesSkuList,
|
||||
fetchAdminCloudtentaclesVnCode,
|
||||
fetchAdminCloudtentaclesVnList,
|
||||
generateAdminCloudtentaclesVnLoginCode,
|
||||
runAdminCloudtentaclesFullFlow,
|
||||
useAdminCloudtentaclesSku,
|
||||
verifyAdminCloudtentaclesVnCode,
|
||||
} from '@/services/admin'
|
||||
|
||||
import {
|
||||
normalizeCloudtentaclesDeviceId,
|
||||
normalizeCloudtentaclesDeviceType,
|
||||
} from './cloudtentaclesDefaults'
|
||||
|
||||
export type CloudtentaclesForm = {
|
||||
baseUrl: string
|
||||
username: string
|
||||
password: string
|
||||
phone: string
|
||||
code: string
|
||||
token: string
|
||||
vnKey: string
|
||||
vnId: number
|
||||
skuId: number
|
||||
skuCount: number
|
||||
deviceId: string
|
||||
deviceType: number
|
||||
}
|
||||
|
||||
export function useCloudtentaclesDebugActions(
|
||||
cloudtentaclesForm: { value: CloudtentaclesForm },
|
||||
cloudtentaclesResultError: { value: string },
|
||||
selectedSourceKey: { value: string },
|
||||
) {
|
||||
const cloudtentaclesDebugLoading = ref(false)
|
||||
const cloudtentaclesDebugResult = ref('')
|
||||
|
||||
function buildCloudtentaclesTokenPayload() {
|
||||
return {
|
||||
baseUrl: cloudtentaclesForm.value.baseUrl.trim() || 'https://123.207.217.176',
|
||||
token: cloudtentaclesForm.value.token.trim(),
|
||||
deviceId: normalizeCloudtentaclesDeviceId(cloudtentaclesForm.value.deviceId),
|
||||
deviceType: normalizeCloudtentaclesDeviceType(cloudtentaclesForm.value.deviceType),
|
||||
sourceKey: selectedSourceKey.value,
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCloudtentaclesToken() {
|
||||
if (!cloudtentaclesForm.value.token.trim()) {
|
||||
cloudtentaclesResultError.value = '请先登录或手动填写 token'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function renderCloudtentaclesDebugResult(label: string, data: unknown) {
|
||||
cloudtentaclesDebugResult.value = `${label}\n${JSON.stringify(data, null, 2)}`
|
||||
}
|
||||
|
||||
async function runCloudtentaclesDebugAction(
|
||||
label: string,
|
||||
runner: () => Promise<{ data: unknown }>,
|
||||
) {
|
||||
cloudtentaclesDebugLoading.value = true
|
||||
cloudtentaclesResultError.value = ''
|
||||
|
||||
try {
|
||||
const response = await runner()
|
||||
renderCloudtentaclesDebugResult(label, response.data)
|
||||
showSuccess(`${label}成功`)
|
||||
} catch (error) {
|
||||
cloudtentaclesResultError.value = error instanceof Error ? error.message : `${label}失败`
|
||||
showError(cloudtentaclesResultError.value)
|
||||
} finally {
|
||||
cloudtentaclesDebugLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesFetchAsset() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('查询余额', () =>
|
||||
fetchAdminCloudtentaclesAsset(buildCloudtentaclesTokenPayload()),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesFetchCategories() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('查询分类', () =>
|
||||
fetchAdminCloudtentaclesCategories(buildCloudtentaclesTokenPayload()),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesFetchSkuList() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('查询SKU列表', () =>
|
||||
fetchAdminCloudtentaclesSkuList(buildCloudtentaclesTokenPayload()),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesBuySku() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('购买SKU', () =>
|
||||
buyAdminCloudtentaclesSku({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
id: Number(cloudtentaclesForm.value.skuId || 0),
|
||||
count: Number(cloudtentaclesForm.value.skuCount || 1),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesUseSku() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('执行发货', () =>
|
||||
useAdminCloudtentaclesSku({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
id: Number(cloudtentaclesForm.value.skuId || 0),
|
||||
virtualNumberId: Number(cloudtentaclesForm.value.vnId || 0),
|
||||
phone: cloudtentaclesForm.value.phone.trim(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesFetchKnapsack() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('查询背包', () =>
|
||||
fetchAdminCloudtentaclesKnapsack(buildCloudtentaclesTokenPayload()),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesFetchVnList() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('查询虚拟号列表', () =>
|
||||
fetchAdminCloudtentaclesVnList({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesAppointVn() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('申请虚拟号', async () => {
|
||||
const response = await appointAdminCloudtentaclesVn({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
})
|
||||
const item = (response.data as { item?: { id?: number; phone?: string } }).item
|
||||
if (item?.id) {
|
||||
cloudtentaclesForm.value.vnId = Number(item.id)
|
||||
}
|
||||
if (item?.phone) {
|
||||
cloudtentaclesForm.value.phone = String(item.phone)
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesGenerateVnLoginCode() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('生成登录码', () =>
|
||||
generateAdminCloudtentaclesVnLoginCode({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
id: Number(cloudtentaclesForm.value.vnId || 0),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesFetchVnCode() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('获取验证码', async () => {
|
||||
const response = await fetchAdminCloudtentaclesVnCode({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
phone: cloudtentaclesForm.value.phone.trim(),
|
||||
})
|
||||
const code = (response.data as { code?: string }).code
|
||||
if (code) {
|
||||
cloudtentaclesForm.value.code = String(code)
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesVerifyVnCode() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('校验验证码', () =>
|
||||
verifyAdminCloudtentaclesVnCode({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
id: Number(cloudtentaclesForm.value.vnId || 0),
|
||||
code: cloudtentaclesForm.value.code.trim(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesFetchBindUrl() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('获取兑换链接', () =>
|
||||
fetchAdminCloudtentaclesBindUrl({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
id: Number(cloudtentaclesForm.value.vnId || 0),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesBackVn() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('退还号码', () =>
|
||||
backAdminCloudtentaclesVn({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
key: cloudtentaclesForm.value.vnKey.trim(),
|
||||
id: Number(cloudtentaclesForm.value.vnId || 0),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function handleCloudtentaclesRunFullFlow() {
|
||||
if (!ensureCloudtentaclesToken()) return
|
||||
await runCloudtentaclesDebugAction('完整测试流程', () =>
|
||||
runAdminCloudtentaclesFullFlow({
|
||||
...buildCloudtentaclesTokenPayload(),
|
||||
skuId: Number(cloudtentaclesForm.value.skuId || 0),
|
||||
skuCount: Number(cloudtentaclesForm.value.skuCount || 1),
|
||||
vnKey: cloudtentaclesForm.value.vnKey.trim(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
cloudtentaclesDebugLoading,
|
||||
cloudtentaclesDebugResult,
|
||||
buildCloudtentaclesTokenPayload,
|
||||
ensureCloudtentaclesToken,
|
||||
handleCloudtentaclesFetchAsset,
|
||||
handleCloudtentaclesFetchCategories,
|
||||
handleCloudtentaclesFetchSkuList,
|
||||
handleCloudtentaclesBuySku,
|
||||
handleCloudtentaclesUseSku,
|
||||
handleCloudtentaclesFetchKnapsack,
|
||||
handleCloudtentaclesFetchVnList,
|
||||
handleCloudtentaclesAppointVn,
|
||||
handleCloudtentaclesGenerateVnLoginCode,
|
||||
handleCloudtentaclesFetchVnCode,
|
||||
handleCloudtentaclesVerifyVnCode,
|
||||
handleCloudtentaclesFetchBindUrl,
|
||||
handleCloudtentaclesBackVn,
|
||||
handleCloudtentaclesRunFullFlow,
|
||||
}
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import type { EditableKuaishouEticketShop } from './types'
|
||||
import type { AdminKuaishouEticketShopConfigItem } from '@/types/admin'
|
||||
|
||||
export function useKuaishouEticketShopManager() {
|
||||
const kuaishouEticketShops = ref<EditableKuaishouEticketShop[]>([])
|
||||
|
||||
function addKuaishouEticketShop() {
|
||||
const shop = createEmptyKuaishouEticketShop()
|
||||
kuaishouEticketShops.value.unshift(shop)
|
||||
return { shop, newSelectedId: shop.id }
|
||||
}
|
||||
|
||||
function removeKuaishouEticketShop(id: string, currentSelectedId: string) {
|
||||
kuaishouEticketShops.value = kuaishouEticketShops.value.filter((item) => item.id !== id)
|
||||
if (currentSelectedId === id) {
|
||||
return kuaishouEticketShops.value[0]?.id || ''
|
||||
}
|
||||
return currentSelectedId
|
||||
}
|
||||
|
||||
function describeKuaishouEticketShop(shop: EditableKuaishouEticketShop) {
|
||||
const status = shop.cookie.trim() ? 'Cookie 已就绪' : '待补 Cookie'
|
||||
const identity = shop.shopId.trim() ? '已识别店铺' : '待读取信息'
|
||||
return `${status} · ${identity}`
|
||||
}
|
||||
|
||||
function getKuaishouEticketShopInitial(shop: EditableKuaishouEticketShop) {
|
||||
const label = shop.kshopName.trim() || shop.shopId.trim() || '店'
|
||||
return label.slice(0, 1).toUpperCase()
|
||||
}
|
||||
|
||||
function setShopsFromConfig(shops: AdminKuaishouEticketShopConfigItem[]) {
|
||||
kuaishouEticketShops.value = shops.map(mapEditableKuaishouEticketShop)
|
||||
return kuaishouEticketShops.value
|
||||
}
|
||||
|
||||
function findShopByShopId(shopId: string) {
|
||||
return kuaishouEticketShops.value.find((item) => item.shopId && item.shopId === shopId)
|
||||
}
|
||||
|
||||
return {
|
||||
kuaishouEticketShops,
|
||||
addKuaishouEticketShop,
|
||||
removeKuaishouEticketShop,
|
||||
describeKuaishouEticketShop,
|
||||
getKuaishouEticketShopInitial,
|
||||
setShopsFromConfig,
|
||||
findShopByShopId,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyKuaishouEticketShop(): EditableKuaishouEticketShop {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
shopId: '',
|
||||
kshopName: '',
|
||||
cookie: '',
|
||||
userAvatar: '',
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
function mapEditableKuaishouEticketShop(
|
||||
item: AdminKuaishouEticketShopConfigItem,
|
||||
): EditableKuaishouEticketShop {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
shopId: item.shopId,
|
||||
kshopName: item.kshopName,
|
||||
cookie: item.cookie,
|
||||
userAvatar: item.userAvatar,
|
||||
enabled: item.enabled !== false,
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
|
||||
export type RememberedCollapseState = {
|
||||
activeNames: Ref<string[]>
|
||||
handleChange: (value: unknown) => void
|
||||
}
|
||||
|
||||
export function useRememberedCollapse(
|
||||
storageKey: string,
|
||||
defaultValue: string[] = [],
|
||||
): RememberedCollapseState {
|
||||
const activeNames = ref<string[]>(readStoredCollapseNames(storageKey, defaultValue))
|
||||
|
||||
function handleChange(value: unknown) {
|
||||
const nextNames = normalizeCollapseNames(value)
|
||||
activeNames.value = nextNames
|
||||
writeStoredCollapseNames(storageKey, nextNames)
|
||||
}
|
||||
|
||||
watch(
|
||||
activeNames,
|
||||
(names) => {
|
||||
writeStoredCollapseNames(storageKey, names)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
return {
|
||||
activeNames,
|
||||
handleChange,
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredCollapseNames(storageKey: string, defaultValue: string[]): string[] {
|
||||
if (!canUseLocalStorage()) {
|
||||
return [...defaultValue]
|
||||
}
|
||||
|
||||
try {
|
||||
const rawValue = window.localStorage.getItem(storageKey)
|
||||
if (!rawValue) {
|
||||
return [...defaultValue]
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(rawValue)
|
||||
return normalizeCollapseNames(parsed, defaultValue)
|
||||
} catch {
|
||||
return [...defaultValue]
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredCollapseNames(storageKey: string, names: unknown): void {
|
||||
if (!canUseLocalStorage()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(normalizeCollapseNames(names)))
|
||||
} catch {
|
||||
// 浏览器禁用存储时忽略,折叠状态退化为本次会话内有效。
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCollapseNames(value: unknown, fallback: string[] = []): string[] {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized ? [normalized] : []
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return [...fallback]
|
||||
}
|
||||
|
||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== 'undefined' && Boolean(window.localStorage)
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
.admin-panel {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.claim-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.section-card p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-card h3 {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 26%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.96));
|
||||
}
|
||||
|
||||
.hero-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-copy h2 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-copy p,
|
||||
.hero-inline span,
|
||||
.hero-link-row span {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-eyebrow {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-meta-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1px;
|
||||
background: var(--border-default);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-meta-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-metric {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 10px var(--space-4);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.hero-metric span {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-metric strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hero-inline,
|
||||
.hero-link-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.auto-delivery-tag-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auto-delivery-note {
|
||||
margin-top: 14px;
|
||||
padding: 14px var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.auto-delivery-note p {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.auto-delivery-note p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.status-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.redeem-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.metric-card,
|
||||
.redeem-attempt-card {
|
||||
padding: 14px var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default);
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
}
|
||||
|
||||
.metric-card span,
|
||||
.redeem-attempt-grid span {
|
||||
display: block;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.metric-card strong,
|
||||
.redeem-attempt-grid strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.redeem-attempts {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.redeem-attempt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.redeem-outcome {
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.redeem-attempt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.redeem-attempt-copy {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.binding-primary {
|
||||
margin-top: 4px;
|
||||
color: var(--color-success);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.binding-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.manual-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-panel :deep(.el-form-item__label) {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.manual-hint {
|
||||
margin: 14px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.manual-summary {
|
||||
margin-top: var(--space-4);
|
||||
padding: 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.manual-summary p {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.manual-summary p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.flow-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(37, 99, 235, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96));
|
||||
}
|
||||
|
||||
.flow-card-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.flow-head-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.kuaishou-focus-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.flow-block {
|
||||
padding: var(--space-4);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border-default);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.flow-block--spotlight {
|
||||
background:
|
||||
linear-gradient(160deg, rgba(255, 255, 255, 0.98), rgba(239, 246, 255, 0.95)),
|
||||
rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.flow-block h4 {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.flow-block p {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.flow-meta-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.flow-meta-list div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.flow-meta-list span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.flow-meta-list strong {
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.flow-meta-list--dense {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.flow-meta-list--two-column {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.spotlight-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.spotlight-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.flow-copy {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.flow-collapse {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* ===== Flow Timeline (Checklist) ===== */
|
||||
.flow-timeline {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin-top: var(--space-4);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.flow-timeline-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 0 var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Vertical connector line */
|
||||
.flow-timeline-item:not(:last-child) .timeline-gutter::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 24px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-default);
|
||||
}
|
||||
|
||||
.timeline-gutter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
background: var(--border-default);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.flow-timeline-item--done .timeline-dot {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.flow-timeline-item--current .timeline-dot {
|
||||
background: var(--text-primary);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.flow-timeline-item--error .timeline-dot {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
padding: 8px 0 14px;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.flow-timeline-item--current .timeline-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-current-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline-detail {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.flow-timeline-item--done .timeline-detail {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ===== Collapse Badge ===== */
|
||||
.collapse-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.collapse-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 0 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(100, 116, 139, 0.08);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ===== Event Timeline ===== */
|
||||
.event-timeline {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.event-tl-item {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 0 var(--space-3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-tl-item:not(:last-child) .event-tl-gutter::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 24px;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--border-default);
|
||||
}
|
||||
|
||||
.event-tl-gutter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.event-tl-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.event-tl-body {
|
||||
padding: 8px 0 14px;
|
||||
}
|
||||
|
||||
.event-tl-type {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.event-tl-payload {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.event-tl-time {
|
||||
margin-top: 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* ===== Role Enhancements ===== */
|
||||
.role-verified-badge {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.role-pending-hint {
|
||||
margin-top: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.role-hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.role-hero div {
|
||||
padding: 14px var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.role-hero span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.role-hero strong {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--text-lg);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.role-warning {
|
||||
margin: 14px 0 0;
|
||||
padding: var(--space-3) 14px;
|
||||
border-radius: 14px;
|
||||
background: var(--color-warning-light);
|
||||
color: #b54708;
|
||||
}
|
||||
|
||||
.event-copy {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.event-time {
|
||||
margin-top: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.screenshot-link {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.screenshot-empty {
|
||||
margin: 0 0 var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.screenshot-preview {
|
||||
display: block;
|
||||
width: min(100%, 720px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--border-default);
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-head,
|
||||
.hero-inline,
|
||||
.hero-link-row,
|
||||
.flow-card-head,
|
||||
.flow-head-actions,
|
||||
.spotlight-head,
|
||||
.spotlight-actions {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.timeline-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
import {
|
||||
closeAdminTask,
|
||||
markAdminTaskManualReview,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
retryAdminTask,
|
||||
} from '@/services/admin'
|
||||
|
||||
import AdminTaskKuaishouCloudPanel from './components/AdminTaskKuaishouCloudPanel.vue'
|
||||
import AdminTaskActionBar from './components/AdminTaskActionBar.vue'
|
||||
import AdminTaskEventsSection from './components/AdminTaskEventsSection.vue'
|
||||
import AdminTaskHeroSection from './components/AdminTaskHeroSection.vue'
|
||||
import AdminTaskInfoSection from './components/AdminTaskInfoSection.vue'
|
||||
import AdminTaskManualDispatchSection from './components/AdminTaskManualDispatchSection.vue'
|
||||
import AdminTaskRedeemResolutionSection from './components/AdminTaskRedeemResolutionSection.vue'
|
||||
import AdminTaskScreenshotSection from './components/AdminTaskScreenshotSection.vue'
|
||||
import { useAdminTaskDetail } from './composables/useAdminTaskDetail'
|
||||
import { useAdminTaskKuaishouCloud } from './composables/useAdminTaskKuaishouCloud'
|
||||
|
||||
const {
|
||||
loading,
|
||||
actionLoading,
|
||||
errorMessage,
|
||||
detail,
|
||||
screenshotPreviewUrl,
|
||||
manualDispatchForm,
|
||||
canManageTaskLifecycle,
|
||||
canCloseTasks,
|
||||
canViewSensitiveTaskData,
|
||||
claimUrl,
|
||||
claimLinkInvalid,
|
||||
screenshotSectionTitle,
|
||||
loadDetail,
|
||||
runAction,
|
||||
submitManualDispatch,
|
||||
copyText,
|
||||
openExternalLink,
|
||||
} = useAdminTaskDetail()
|
||||
|
||||
const {
|
||||
kuaishouCloudFlow,
|
||||
isKuaishouCloudTask,
|
||||
summaryCards,
|
||||
kuaishouRoleInfoEntries,
|
||||
kuaishouCloudChecklist,
|
||||
submitKuaishouCloudPrepare,
|
||||
submitKuaishouCloudDispatch,
|
||||
submitKuaishouCloudRefreshRoleInfo,
|
||||
submitKuaishouCloudReturnNumber,
|
||||
} = useAdminTaskKuaishouCloud(detail, runAction)
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-panel">
|
||||
<AdminPageHeader title="任务详情" description="查看任务全量信息并执行人工操作。" />
|
||||
|
||||
<el-alert v-if="errorMessage" :title="errorMessage" type="error" show-icon :closable="false" />
|
||||
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<AdminTaskHeroSection
|
||||
:detail="detail"
|
||||
:is-kuaishou-cloud-task="isKuaishouCloudTask"
|
||||
:summary-cards="summaryCards"
|
||||
:claim-url="claimUrl"
|
||||
:claim-link-invalid="claimLinkInvalid"
|
||||
/>
|
||||
|
||||
<AdminTaskActionBar
|
||||
:detail="detail"
|
||||
:action-loading="actionLoading"
|
||||
:can-manage-task-lifecycle="canManageTaskLifecycle"
|
||||
:can-close-tasks="canCloseTasks"
|
||||
@retry="
|
||||
runAction(
|
||||
() => retryAdminTask(detail!.task.taskId),
|
||||
'任务已重试',
|
||||
`确认重试任务 ${detail!.task.taskNo} 吗?`,
|
||||
)
|
||||
"
|
||||
@rebind-role="
|
||||
runAction(
|
||||
() => rebindAdminTaskKuaishouCloudRole(detail!.task.taskId),
|
||||
'角色换绑资源已准备完成',
|
||||
`确认为任务 ${detail!.task.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
)
|
||||
"
|
||||
@prepare-kuaishou-cloud="submitKuaishouCloudPrepare"
|
||||
@dispatch-kuaishou-cloud="submitKuaishouCloudDispatch"
|
||||
@return-kuaishou-cloud="submitKuaishouCloudReturnNumber"
|
||||
@mark-manual-review="
|
||||
runAction(
|
||||
() => markAdminTaskManualReview(detail!.task.taskId),
|
||||
'任务已转人工处理',
|
||||
`确认将任务 ${detail!.task.taskNo} 转为人工处理吗?`,
|
||||
)
|
||||
"
|
||||
@submit-manual-dispatch="submitManualDispatch"
|
||||
@close-task="
|
||||
runAction(
|
||||
() => closeAdminTask(detail!.task.taskId),
|
||||
'任务已关闭',
|
||||
`确认关闭任务 ${detail!.task.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<AdminTaskInfoSection
|
||||
v-if="!isKuaishouCloudTask"
|
||||
:detail="detail"
|
||||
:can-view-sensitive-task-data="canViewSensitiveTaskData"
|
||||
:claim-url="claimUrl"
|
||||
:claim-link-invalid="claimLinkInvalid"
|
||||
/>
|
||||
|
||||
<AdminTaskKuaishouCloudPanel
|
||||
v-if="isKuaishouCloudTask && kuaishouCloudFlow"
|
||||
:detail="detail"
|
||||
:flow="kuaishouCloudFlow"
|
||||
:role-info-entries="kuaishouRoleInfoEntries"
|
||||
:checklist="kuaishouCloudChecklist"
|
||||
:claim-url="claimUrl"
|
||||
:action-loading="actionLoading"
|
||||
@refresh-role="submitKuaishouCloudRefreshRoleInfo"
|
||||
@copy-bind-url="copyText($event, '绑定链接已复制')"
|
||||
@open-bind-url="openExternalLink"
|
||||
/>
|
||||
|
||||
<AdminTaskRedeemResolutionSection v-if="detail.redeemResolution" :detail="detail" />
|
||||
|
||||
<AdminTaskManualDispatchSection
|
||||
v-if="detail.task.executorKey === 'manual_dispatch'"
|
||||
:detail="detail"
|
||||
:form="manualDispatchForm"
|
||||
:can-manage-task-lifecycle="canManageTaskLifecycle"
|
||||
:action-loading="actionLoading"
|
||||
@update:form="Object.assign(manualDispatchForm, $event)"
|
||||
@submit="submitManualDispatch"
|
||||
/>
|
||||
|
||||
<AdminTaskEventsSection v-if="detail.events.length > 0" :detail="detail" />
|
||||
|
||||
<AdminTaskScreenshotSection
|
||||
v-if="detail.screenshotUrl"
|
||||
:screenshot-preview-url="screenshotPreviewUrl"
|
||||
:section-title="screenshotSectionTitle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<el-empty v-else description="任务不存在或已被删除" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./AdminTaskDetail.css"></style>
|
||||
@@ -1,495 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { useAdminAction } from '@/composables/useAdminAction'
|
||||
import { useAdminListPage } from '@/composables/useAdminListPage'
|
||||
import AdminPageHeader from '@/components/admin/AdminPageHeader.vue'
|
||||
import {
|
||||
closeAdminTask,
|
||||
fetchAdminTasks,
|
||||
markAdminTaskManualReview,
|
||||
rebindAdminTaskKuaishouCloudRole,
|
||||
retryAdminTask,
|
||||
} from '@/services/admin'
|
||||
import type { AdminTaskListItem } from '@/types/admin'
|
||||
import { hasAdminRole } from '@/utils/admin-auth'
|
||||
import { adminTaskStatusOptions } from '@/utils/admin-options'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const status = ref('')
|
||||
const taskNo = ref('')
|
||||
const platformOrderId = ref('')
|
||||
const roleId = ref('')
|
||||
const skuCode = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const lastClaimUrl = ref('')
|
||||
|
||||
const canManageTaskLifecycle = hasAdminRole('operator')
|
||||
const canCloseTasks = hasAdminRole('support')
|
||||
|
||||
type AdminTaskActionKey = 'retry' | 'rebind_role' | 'manual_review' | 'close'
|
||||
|
||||
const {
|
||||
loading,
|
||||
errorMessage,
|
||||
items,
|
||||
pagination,
|
||||
loadPage: loadTasks,
|
||||
} = useAdminListPage<AdminTaskListItem>({
|
||||
defaultErrorMessage: '读取任务列表失败',
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchAdminTasks({
|
||||
page,
|
||||
pageSize,
|
||||
status: status.value.trim(),
|
||||
taskNo: taskNo.value.trim(),
|
||||
platformOrderId: platformOrderId.value.trim(),
|
||||
roleId: roleId.value.trim(),
|
||||
skuCode: skuCode.value.trim(),
|
||||
dateFrom: dateFrom.value.trim(),
|
||||
dateTo: dateTo.value.trim(),
|
||||
}),
|
||||
})
|
||||
|
||||
const { actionLoadingId, runAction } = useAdminAction()
|
||||
|
||||
function handleActionCommand(actionKey: AdminTaskActionKey, item: AdminTaskListItem) {
|
||||
if (actionKey === 'retry') {
|
||||
void runAction({
|
||||
id: item.taskId,
|
||||
action: async () => {
|
||||
const response = await retryAdminTask(item.taskId)
|
||||
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
|
||||
},
|
||||
successMessage: '任务已重试',
|
||||
confirmText: `确认重试任务 ${item.taskNo} 吗?`,
|
||||
onAfterAction: async () => {
|
||||
await loadTasks()
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (actionKey === 'rebind_role') {
|
||||
void runAction({
|
||||
id: item.taskId,
|
||||
action: async () => {
|
||||
const response = await rebindAdminTaskKuaishouCloudRole(item.taskId)
|
||||
if (response.data.claimUrl) lastClaimUrl.value = response.data.claimUrl
|
||||
},
|
||||
successMessage: '角色换绑资源已准备完成',
|
||||
confirmText: `确认为任务 ${item.taskNo} 换绑角色吗?当前虚拟号会退还,并生成新的绑定二维码。`,
|
||||
onAfterAction: async () => {
|
||||
await loadTasks()
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (actionKey === 'manual_review') {
|
||||
void runAction({
|
||||
id: item.taskId,
|
||||
action: () => markAdminTaskManualReview(item.taskId),
|
||||
successMessage: '任务已转人工处理',
|
||||
confirmText: `确认将任务 ${item.taskNo} 转为人工处理吗?`,
|
||||
onAfterAction: async () => {
|
||||
await loadTasks()
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
void runAction({
|
||||
id: item.taskId,
|
||||
action: () => closeAdminTask(item.taskId),
|
||||
successMessage: '任务已关闭',
|
||||
confirmText: `确认关闭任务 ${item.taskNo} 吗?关闭后不会自动继续推进。`,
|
||||
onAfterAction: async () => {
|
||||
await loadTasks()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
status.value = ''
|
||||
taskNo.value = ''
|
||||
platformOrderId.value = ''
|
||||
roleId.value = ''
|
||||
skuCode.value = ''
|
||||
dateFrom.value = ''
|
||||
dateTo.value = ''
|
||||
void loadTasks(1)
|
||||
}
|
||||
|
||||
function resolveResourceStageLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
const labelMap: Record<string, string> = {
|
||||
resource_ready: '资源已准备',
|
||||
pending_prepare: '待准备资源',
|
||||
pending_payment: '待支付',
|
||||
pending_binding_prepare: '待准备资源',
|
||||
retry_pending: '待重试',
|
||||
manual_review: '人工处理',
|
||||
resource_exception: '资源异常',
|
||||
closed: '已关闭',
|
||||
expired: '已过期',
|
||||
completed: '已完成',
|
||||
not_started: '未开始',
|
||||
}
|
||||
|
||||
return labelMap[normalized] || normalized || '-'
|
||||
}
|
||||
|
||||
function resolveCustomerStageLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
const labelMap: Record<string, string> = {
|
||||
not_started: '待打开链接',
|
||||
waiting_customer: '待客户绑定',
|
||||
link_opened: '已打开链接',
|
||||
customer_processing: '处理中',
|
||||
customer_confirmed: '已提交',
|
||||
customer_completed: '客户已完成',
|
||||
customer_exception: '客户步骤异常',
|
||||
closed: '已关闭',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
return labelMap[normalized] || normalized || '-'
|
||||
}
|
||||
|
||||
function resolveTaskMainText(item: AdminTaskListItem) {
|
||||
if (item.lastError) return '需要关注'
|
||||
if (['failed', 'manual_review', 'retry_pending'].includes(item.status)) return '待处理'
|
||||
if (['redeemed', 'completed', 'closed'].includes(item.status)) return '已完成'
|
||||
if (item.customerStatus === 'waiting_customer') return '等待客户绑定'
|
||||
if (item.resourceStatus === 'resource_ready') return '资源已准备'
|
||||
if (item.status === 'pending_binding_prepare') return '待准备资源'
|
||||
return item.status || '处理中'
|
||||
}
|
||||
|
||||
function resolveTaskTone(item: AdminTaskListItem) {
|
||||
if (item.lastError || ['failed', 'manual_review', 'retry_pending'].includes(item.status))
|
||||
return 'danger'
|
||||
if (['redeemed', 'completed', 'closed'].includes(item.status)) return 'success'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function canRebindListTask(item: AdminTaskListItem) {
|
||||
return (
|
||||
canManageTaskLifecycle &&
|
||||
item.executorKey === 'kuaishou_ct_assisted' &&
|
||||
['waiting_binding', 'role_confirmed', 'manual_review', 'retry_pending'].includes(item.status)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadTasks(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tasks-page list-page">
|
||||
<AdminPageHeader title="交付任务" description="查看自动履约任务、客户步骤和异常处理。">
|
||||
<template #extra>
|
||||
<span class="total-badge">共 {{ pagination.total }} 条任务</span>
|
||||
</template>
|
||||
</AdminPageHeader>
|
||||
|
||||
<el-card shadow="never" class="section-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">查询任务</span>
|
||||
<span class="card-desc">按状态、任务号、订单号、商品和日期筛选。</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="filter-grid">
|
||||
<el-select v-model="status" placeholder="状态" clearable>
|
||||
<el-option
|
||||
v-for="opt in adminTaskStatusOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="taskNo"
|
||||
placeholder="任务号(模糊匹配)"
|
||||
clearable
|
||||
@keyup.enter="loadTasks(1)"
|
||||
/>
|
||||
<el-input
|
||||
v-model="platformOrderId"
|
||||
placeholder="平台订单号"
|
||||
clearable
|
||||
@keyup.enter="loadTasks(1)"
|
||||
/>
|
||||
<el-input v-model="roleId" placeholder="角色 ID" clearable @keyup.enter="loadTasks(1)" />
|
||||
<el-input
|
||||
v-model="skuCode"
|
||||
placeholder="商品名 / SKU"
|
||||
clearable
|
||||
@keyup.enter="loadTasks(1)"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<div class="filter-buttons">
|
||||
<el-button round @click="resetFilters">重置</el-button>
|
||||
<el-button round type="primary" @click="loadTasks(1)">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="lastClaimUrl"
|
||||
:title="`最近可用领取链接:${lastClaimUrl}`"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<el-card
|
||||
shadow="never"
|
||||
class="section-card"
|
||||
v-loading="loading"
|
||||
element-loading-text="任务列表加载中"
|
||||
>
|
||||
<template v-if="items.length === 0 && !loading">
|
||||
<el-empty description="当前筛选条件下暂无任务" :image-size="60" />
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="tasks-toolbar table-toolbar">
|
||||
<strong>任务列表</strong>
|
||||
<span>第 {{ pagination.page }} 页,当前展示 {{ items.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" stripe size="small" class="tasks-table data-table">
|
||||
<el-table-column label="任务 / 订单" min-width="230">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<RouterLink
|
||||
:to="`/admin/tasks/${row.taskId}`"
|
||||
class="task-no id-link"
|
||||
:title="row.taskNo"
|
||||
>
|
||||
{{ row.taskNo }}
|
||||
</RouterLink>
|
||||
<div class="cell-meta-row">
|
||||
<span class="compact-chip" :title="row.platformOrderId"
|
||||
>订单 {{ row.platformOrderId }}</span
|
||||
>
|
||||
<span class="compact-chip compact-chip--muted">重试 {{ row.retryCount }} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-title" :title="row.skuName || row.skuCode">{{
|
||||
row.skuName || row.skuCode
|
||||
}}</span>
|
||||
<span class="cell-subline">SKU {{ row.skuCode || '-' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<div class="task-status-summary">
|
||||
<span class="task-main-chip" :data-tone="resolveTaskTone(row)">
|
||||
{{ resolveTaskMainText(row) }}
|
||||
</span>
|
||||
<p class="task-status-note">
|
||||
客户 {{ resolveCustomerStageLabel(row.customerStatus) }} · 资源
|
||||
{{ resolveResourceStageLabel(row.resourceStatus) }}
|
||||
</p>
|
||||
<p v-if="row.roleName || row.roleId" class="task-status-note">
|
||||
角色 {{ row.roleName || '-' }} / {{ row.roleId || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-subline">创建 {{ formatAdminDateTime(row.createdAt) }}</span>
|
||||
<span class="cell-subline">更新 {{ formatAdminDateTime(row.updatedAt) }}</span>
|
||||
<span v-if="row.claimedAt" class="cell-subline"
|
||||
>领取 {{ formatAdminDateTime(row.claimedAt) }}</span
|
||||
>
|
||||
<span v-if="row.roleConfirmedAt" class="cell-subline"
|
||||
>确认 {{ formatAdminDateTime(row.roleConfirmedAt) }}</span
|
||||
>
|
||||
<span v-if="row.redeemedAt" class="cell-subline"
|
||||
>兑换 {{ formatAdminDateTime(row.redeemedAt) }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="异常 / 操作" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-stack">
|
||||
<span class="cell-error" :title="row.lastError || '-'">{{
|
||||
row.lastError || '当前无异常'
|
||||
}}</span>
|
||||
<div class="action-stack">
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="
|
||||
row.executorKey === 'manual_dispatch' ||
|
||||
!['retry_pending', 'manual_review'].includes(row.status)
|
||||
"
|
||||
:loading="actionLoadingId === row.taskId"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
@click="handleActionCommand('retry', row)"
|
||||
>
|
||||
重试
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canRebindListTask(row)"
|
||||
size="small"
|
||||
@click="handleActionCommand('rebind_role', row)"
|
||||
>
|
||||
换绑角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
size="small"
|
||||
@click="handleActionCommand('manual_review', row)"
|
||||
>
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canCloseTasks && !['redeemed', 'closed'].includes(row.status)"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleActionCommand('close', row)"
|
||||
>
|
||||
关闭任务
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-if="pagination.total > 0"
|
||||
v-model:current-page="pagination.page"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
class="pagination-center"
|
||||
@current-change="loadTasks"
|
||||
/>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@import '../../../styles/admin-list-pages.css';
|
||||
|
||||
.filter-grid {
|
||||
grid-template-columns:
|
||||
minmax(150px, 0.75fr) minmax(220px, 1fr) minmax(220px, 1fr)
|
||||
minmax(210px, 0.95fr);
|
||||
}
|
||||
.filter-grid :deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-status-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-main-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid transparent;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-main-chip[data-tone='success'] {
|
||||
color: #0f766e;
|
||||
background: var(--color-success-light);
|
||||
border-color: #a6f4c5;
|
||||
}
|
||||
|
||||
.task-main-chip[data-tone='warning'] {
|
||||
color: #b54708;
|
||||
background: var(--color-warning-light);
|
||||
border-color: #fedf89;
|
||||
}
|
||||
|
||||
.task-main-chip[data-tone='danger'] {
|
||||
color: var(--color-danger-dark);
|
||||
background: var(--color-danger-light);
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.task-status-note {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.task-status-note + .task-status-note {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.action-stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.action-stack :deep(.el-button) {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,114 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
actionLoading: boolean
|
||||
canManageTaskLifecycle: boolean
|
||||
canCloseTasks: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
rebindRole: []
|
||||
prepareKuaishouCloud: []
|
||||
dispatchKuaishouCloud: []
|
||||
returnKuaishouCloud: []
|
||||
markManualReview: []
|
||||
submitManualDispatch: [outcome: 'delivered' | 'failed']
|
||||
closeTask: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="action-row">
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="!detail.operations.canRetry"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
重试任务
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="detail.operations.canRebindKuaishouCloudRole"
|
||||
:disabled="!detail.operations.canRebindKuaishouCloudRole"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('rebindRole')"
|
||||
>
|
||||
换绑角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canPrepareKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="primary"
|
||||
@click="emit('prepareKuaishouCloud')"
|
||||
>
|
||||
准备绑定资源
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canDispatchKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('dispatchKuaishouCloud')"
|
||||
>
|
||||
确认绑定完成并发货
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canReturnKuaishouCloudFulfillment"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="warning"
|
||||
@click="emit('returnKuaishouCloud')"
|
||||
>
|
||||
退还号码
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle"
|
||||
:disabled="!detail.operations.canMarkManualReview"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('markManualReview')"
|
||||
>
|
||||
转人工
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="success"
|
||||
@click="emit('submitManualDispatch', 'delivered')"
|
||||
>
|
||||
人工完成
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManageTaskLifecycle && detail.operations.canCompleteManualDispatch"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
plain
|
||||
@click="emit('submitManualDispatch', 'failed')"
|
||||
>
|
||||
履约失败
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canCloseTasks"
|
||||
:disabled="!detail.operations.canClose"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
type="danger"
|
||||
@click="emit('closeTask')"
|
||||
>
|
||||
关闭任务
|
||||
</el-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatTaskEventPayload, formatTaskEventType } from '@/utils/admin-display'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>任务事件</h3>
|
||||
<div class="event-timeline">
|
||||
<div
|
||||
v-for="event in detail.events"
|
||||
:key="event.eventType + event.createdAt"
|
||||
class="event-tl-item"
|
||||
>
|
||||
<div class="event-tl-gutter">
|
||||
<div class="event-tl-dot"></div>
|
||||
</div>
|
||||
<div class="event-tl-body">
|
||||
<div class="event-tl-type">{{ formatTaskEventType(event.eventType) }}</div>
|
||||
<div class="event-tl-payload">{{ formatTaskEventPayload(event.payload) }}</div>
|
||||
<div class="event-tl-time">{{ formatAdminDateTime(event.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -1,135 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
|
||||
import type { SummaryCard } from '../composables/types'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
isKuaishouCloudTask: boolean
|
||||
summaryCards: SummaryCard[]
|
||||
claimUrl: string
|
||||
claimLinkInvalid: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
function resolveResourceStageLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
const labelMap: Record<string, string> = {
|
||||
resource_ready: '资源已准备',
|
||||
pending_prepare: '待准备资源',
|
||||
pending_payment: '待支付',
|
||||
pending_binding_prepare: '待准备资源',
|
||||
retry_pending: '待重试',
|
||||
manual_review: '人工处理',
|
||||
resource_exception: '资源异常',
|
||||
closed: '已关闭',
|
||||
expired: '已过期',
|
||||
completed: '已完成',
|
||||
not_started: '未开始',
|
||||
}
|
||||
|
||||
return labelMap[normalized] || normalized || '-'
|
||||
}
|
||||
|
||||
function resolveCustomerStageLabel(status: string) {
|
||||
const normalized = String(status || '').trim()
|
||||
const labelMap: Record<string, string> = {
|
||||
not_started: '待打开链接',
|
||||
waiting_customer: '待客户绑定',
|
||||
link_opened: '已打开链接',
|
||||
customer_processing: '处理中',
|
||||
customer_confirmed: '已提交',
|
||||
customer_completed: '客户已完成',
|
||||
customer_exception: '客户步骤异常',
|
||||
closed: '已关闭',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
return labelMap[normalized] || normalized || '-'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Kuaishou Cloud hero card -->
|
||||
<el-card
|
||||
v-if="isKuaishouCloudTask && detail.kuaishouCloudFulfillment"
|
||||
shadow="never"
|
||||
class="section-card hero-card"
|
||||
>
|
||||
<div class="hero-head">
|
||||
<div class="hero-copy">
|
||||
<span class="hero-eyebrow">自动履约任务</span>
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>
|
||||
订单 {{ detail.order?.platformOrderId || '-' }} · 商品
|
||||
{{ detail.orderItem?.skuName || detail.orderItem?.skuCode || '-' }}
|
||||
· 核销店铺
|
||||
{{ detail.order?.shopName || detail.order?.shopId || '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="status-stack">
|
||||
<AdminStatusTag :status="detail.task.status" :show-raw="false" />
|
||||
<AdminStatusTag :status="detail.order?.payStatus || ''" :show-raw="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-meta-grid">
|
||||
<el-card
|
||||
v-for="card in summaryCards"
|
||||
:key="card.label"
|
||||
shadow="never"
|
||||
class="hero-metric"
|
||||
>
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.value }}</strong>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="hero-inline">
|
||||
<span>资源 {{ resolveResourceStageLabel(detail.task.resourceStatus) }}</span>
|
||||
<span>客户 {{ resolveCustomerStageLabel(detail.task.customerStatus) }}</span>
|
||||
<span v-if="detail.task.lastError">最近异常 {{ detail.task.lastError }}</span>
|
||||
</div>
|
||||
<div v-if="claimUrl" class="hero-link-row">
|
||||
<span>客户领取页</span>
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</div>
|
||||
<div v-else-if="claimLinkInvalid" class="hero-link-row">
|
||||
<span>客户领取页</span>
|
||||
<strong>已失效</strong>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- Simple info card for other task types -->
|
||||
<el-card v-else shadow="never" class="section-card">
|
||||
<h2>{{ detail.task.taskNo }}</h2>
|
||||
<p>
|
||||
状态:<AdminStatusTag :status="detail.task.status" /> · 订单:{{
|
||||
detail.order?.platformOrderId || '-'
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
执行器:{{ detail.task.executorKey || '-' }} · 履约:<AdminStatusTag
|
||||
:status="detail.task.deliveryStatus"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
资源:{{ resolveResourceStageLabel(detail.task.resourceStatus) }} ·
|
||||
客户:{{ resolveCustomerStageLabel(detail.task.customerStatus) }}
|
||||
</p>
|
||||
<p>
|
||||
角色:{{ detail.task.roleName || '-' }} /
|
||||
{{ detail.task.roleId || '-' }}
|
||||
</p>
|
||||
<p>最后错误:{{ detail.task.lastError || '-' }}</p>
|
||||
<p v-if="claimUrl">
|
||||
领取链接:
|
||||
<a class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{ claimUrl }}</a>
|
||||
</p>
|
||||
<p v-else-if="claimLinkInvalid">领取链接:已失效</p>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -1,78 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
canViewSensitiveTaskData: boolean
|
||||
claimUrl: string
|
||||
claimLinkInvalid: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<h3>任务信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="商品">{{
|
||||
detail.orderItem?.skuName || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="SKU">{{
|
||||
detail.orderItem?.skuCode || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务执行器">
|
||||
{{ detail.task.executorKey || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="履约状态">
|
||||
<AdminStatusTag :status="detail.task.deliveryStatus || ''" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="结果代码">{{
|
||||
detail.task.resultCode || '-'
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="结果说明">
|
||||
{{ detail.task.resultMessage || '-' }}
|
||||
</el-descriptions-item>
|
||||
<template v-if="canViewSensitiveTaskData">
|
||||
<el-descriptions-item label="Claim Token">
|
||||
{{ detail.claimToken?.token || '-' }}
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item v-else label="Claim Token" :span="2">
|
||||
客服账号不可查看原始领取 Token
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Token 状态">
|
||||
<AdminStatusTag :status="detail.claimToken?.status || ''" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Token 过期">
|
||||
{{ formatAdminDateTime(detail.claimToken?.expiredAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="领取链接" :span="2">
|
||||
<a v-if="claimUrl" class="claim-link" :href="claimUrl" target="_blank" rel="noreferrer">{{
|
||||
claimUrl
|
||||
}}</a>
|
||||
<span v-else-if="claimLinkInvalid">已失效</span>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="运行上下文">
|
||||
{{ detail.task.runtimeSessionId || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户打开链接">
|
||||
{{ formatAdminDateTime(detail.task.claimedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="客户提交">
|
||||
{{ formatAdminDateTime(detail.task.roleConfirmedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="履约完成">
|
||||
{{ formatAdminDateTime(detail.task.redeemedAt) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="截图路径" :span="2">
|
||||
{{ detail.task.screenshotPath || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
@@ -1,454 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import AdminStatusTag from '@/components/admin/AdminStatusTag.vue'
|
||||
import type { AdminTaskDetail } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type KuaishouCloudFlow = NonNullable<AdminTaskDetail['kuaishouCloudFulfillment']>
|
||||
type RoleInfoEntry = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
type ChecklistItem = {
|
||||
key: string
|
||||
label: string
|
||||
status: string
|
||||
detail: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
detail: AdminTaskDetail
|
||||
flow: KuaishouCloudFlow
|
||||
roleInfoEntries: RoleInfoEntry[]
|
||||
checklist: ChecklistItem[]
|
||||
claimUrl: string
|
||||
actionLoading: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const collapsePanels: string[] = []
|
||||
|
||||
const emit = defineEmits<{
|
||||
refreshRole: []
|
||||
copyBindUrl: [url: string]
|
||||
openBindUrl: [url: string]
|
||||
}>()
|
||||
|
||||
function formatCloudSourcePriority(flow: KuaishouCloudFlow) {
|
||||
return formatAccountNames(flow.binding.cloudSourceLabels, flow.binding.cloudSourceKeys)
|
||||
}
|
||||
|
||||
function formatResolvedCloudSource(flow: KuaishouCloudFlow) {
|
||||
return flow.binding.resolvedSourceLabel || flow.binding.resolvedSourceKey || '-'
|
||||
}
|
||||
|
||||
function formatAccountNames(labels: string[] | undefined, keys: string[] | undefined) {
|
||||
const displayNames = Array.isArray(labels)
|
||||
? labels.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
if (displayNames.length > 0) {
|
||||
return displayNames.join(' / ')
|
||||
}
|
||||
|
||||
const fallbackKeys = Array.isArray(keys)
|
||||
? keys.map((value) => String(value || '').trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
return fallbackKeys.length > 0 ? fallbackKeys.join(' / ') : '-'
|
||||
}
|
||||
|
||||
/** 根据 status 决定每个流程步骤的 modifier class 和图标 */
|
||||
function resolveTimelineItemClass(status: string, isCurrent: boolean) {
|
||||
if (isCurrent) return 'flow-timeline-item--current'
|
||||
const s = String(status || '').trim()
|
||||
if (['verified', 'ready', 'completed', 'done'].includes(s)) return 'flow-timeline-item--done'
|
||||
if (['error', 'failed', 'exception', 'resource_exception'].includes(s))
|
||||
return 'flow-timeline-item--error'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 计算 checklist 中第一个「非完成」的步骤索引,即当前卡点 */
|
||||
function findCurrentStepIndex(checklist: ChecklistItem[]) {
|
||||
const doneStatuses = ['verified', 'ready', 'completed', 'done']
|
||||
const idx = checklist.findIndex(
|
||||
(item) => !doneStatuses.includes(String(item.status || '').trim()),
|
||||
)
|
||||
return idx
|
||||
}
|
||||
|
||||
/** Collapse badge 状态推导 */
|
||||
function resolveClaimBadge(flow: KuaishouCloudFlow, claimUrl: string) {
|
||||
if (flow.ticket.verifiedAt && flow.binding.bindPreparedAt) return 'ok'
|
||||
if (claimUrl) return 'pending'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function resolveTicketBadge(flow: KuaishouCloudFlow) {
|
||||
if (flow.ticket.verifiedAt) return 'ok'
|
||||
if (flow.ticket.code) return 'warn'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function resolveCloudBadge(flow: KuaishouCloudFlow) {
|
||||
if (flow.dispatch.dispatchAt) return 'ok'
|
||||
if (flow.binding.resolvedSourceKey) return 'pending'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function resolveClosureBadge(flow: KuaishouCloudFlow) {
|
||||
if (flow.consume.consumedAt) return 'ok'
|
||||
if (flow.consume.errorMessage) return 'warn'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
function badgeLabel(tone: string) {
|
||||
if (tone === 'ok') return '已完成'
|
||||
if (tone === 'warn') return '需关注'
|
||||
return '待处理'
|
||||
}
|
||||
|
||||
const roleHasIdentity = (flow: KuaishouCloudFlow) => !!(flow.role?.name || flow.role?.rid)
|
||||
|
||||
function formatRebindSource(source: string) {
|
||||
const normalized = String(source || '').trim()
|
||||
if (normalized.includes('claim_page')) return '客户自助'
|
||||
if (normalized.includes('admin')) return '后台操作'
|
||||
return normalized || '-'
|
||||
}
|
||||
|
||||
function formatRebindBinding(
|
||||
binding: { vnId?: number; vnPhone?: string; roleName?: string; roleId?: string } | null,
|
||||
) {
|
||||
if (!binding) return '-'
|
||||
|
||||
const vn = binding.vnId ? `虚拟号 ${binding.vnId}` : '虚拟号 -'
|
||||
const phone = binding.vnPhone ? ` / ${binding.vnPhone}` : ''
|
||||
const role =
|
||||
binding.roleName || binding.roleId
|
||||
? `;角色 ${binding.roleName || '-'} / ${binding.roleId || '-'}`
|
||||
: ''
|
||||
|
||||
return `${vn}${phone}${role}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card flow-card">
|
||||
<div class="flow-card-head">
|
||||
<div>
|
||||
<h3>自动履约流程</h3>
|
||||
<p class="section-copy">
|
||||
优先看当前步骤和客户角色;详细链接、核销、cloud 资源字段可按需展开。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flow-head-actions">
|
||||
<el-button
|
||||
v-if="detail.operations.canRefreshKuaishouCloudRoleInfo"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
@click="emit('refreshRole')"
|
||||
>
|
||||
刷新角色信息
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="flow.binding.bindUrl"
|
||||
round
|
||||
@click="emit('copyBindUrl', flow.binding.bindUrl)"
|
||||
>
|
||||
复制绑定链接
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="flow.binding.bindUrl"
|
||||
round
|
||||
type="primary"
|
||||
plain
|
||||
@click="emit('openBindUrl', flow.binding.bindUrl)"
|
||||
>
|
||||
打开绑定页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kuaishou-focus-grid">
|
||||
<!-- 客户角色卡片 -->
|
||||
<section class="flow-block flow-block--spotlight">
|
||||
<div class="spotlight-head">
|
||||
<div>
|
||||
<h4>客户角色</h4>
|
||||
<p class="flow-copy">客户绑定后刷新角色信息,确认无误后再执行发货。</p>
|
||||
</div>
|
||||
<div class="spotlight-actions">
|
||||
<AdminStatusTag :status="flow.role?.status || 'pending'" />
|
||||
<el-button
|
||||
v-if="detail.operations.canRefreshKuaishouCloudRoleInfo"
|
||||
:loading="actionLoading"
|
||||
round
|
||||
size="small"
|
||||
@click="emit('refreshRole')"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-hero">
|
||||
<div>
|
||||
<span>角色名</span>
|
||||
<strong>{{ flow.role?.name || detail.task.roleName || '等待识别' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>角色 ID</span>
|
||||
<strong>{{ flow.role?.rid || detail.task.roleId || '等待识别' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>最近刷新</span>
|
||||
<strong>{{ formatAdminDateTime(flow.role?.refreshedAt) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 已识别徽章 -->
|
||||
<div v-if="roleHasIdentity(flow)" class="role-verified-badge">✓ 角色已识别</div>
|
||||
<!-- 等待识别引导 -->
|
||||
<div v-else class="role-pending-hint">
|
||||
客户完成绑定后,点击右上角「刷新」按钮获取角色信息,确认角色名与 ID 无误后再执行发货。
|
||||
</div>
|
||||
<p v-if="flow.role?.errorMessage" class="role-warning">
|
||||
{{ flow.role.errorMessage }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 流程进度 Timeline -->
|
||||
<section class="flow-block flow-block--checklist">
|
||||
<h4>流程进度</h4>
|
||||
<p class="flow-copy">当前任务只需要按这个顺序推进,异常时再展开下方明细。</p>
|
||||
<div class="flow-timeline">
|
||||
<div
|
||||
v-for="(item, idx) in checklist"
|
||||
:key="item.key"
|
||||
class="flow-timeline-item"
|
||||
:class="resolveTimelineItemClass(item.status, idx === findCurrentStepIndex(checklist))"
|
||||
>
|
||||
<div class="timeline-gutter">
|
||||
<div class="timeline-dot"></div>
|
||||
</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-head">
|
||||
<span class="timeline-label">{{ item.label }}</span>
|
||||
<span v-if="idx === findCurrentStepIndex(checklist)" class="timeline-current-badge"
|
||||
>当前</span
|
||||
>
|
||||
</div>
|
||||
<div class="timeline-detail">{{ item.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<el-collapse :model-value="collapsePanels" class="flow-collapse">
|
||||
<el-collapse-item name="claim">
|
||||
<template #title>
|
||||
<span class="collapse-title-wrap">
|
||||
客户领取与绑定明细
|
||||
<span class="collapse-badge">
|
||||
{{ badgeLabel(resolveClaimBadge(flow, claimUrl)) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div>
|
||||
<span>客户链接</span><strong>{{ claimUrl || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>绑定链接</span><strong>{{ flow.binding.bindUrl || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Token 状态</span><strong>{{ detail.claimToken?.status || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>准备时间</span
|
||||
><strong>{{ formatAdminDateTime(flow.binding.bindPreparedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>首次提交</span><strong>{{ formatAdminDateTime(flow.ticket.capturedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>校验时间</span><strong>{{ formatAdminDateTime(flow.ticket.verifiedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>虚拟号 ID</span><strong>{{ flow.binding.vnId || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>虚拟号码</span><strong>{{ flow.binding.vnPhone || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="manual-hint">
|
||||
客户会先在领取页查看图文指引并提交核销码,只有校验通过后才会自动跳转绑定链接。
|
||||
</p>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item name="ticket">
|
||||
<template #title>
|
||||
<span class="collapse-title-wrap">
|
||||
核销与商品明细
|
||||
<span class="collapse-badge">
|
||||
{{ badgeLabel(resolveTicketBadge(flow)) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div>
|
||||
<span>核销码</span><strong>{{ flow.ticket.code || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>校验状态</span><strong>{{ flow.ticket.status || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>商品标题</span><strong>{{ flow.ticket.goodsTitle || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>剩余次数</span><strong>{{ flow.ticket.leftCount || 0 }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="manual-hint">
|
||||
"确认绑定完成并发货"已经合并为一个按钮,只有核销码通过校验后才能继续。
|
||||
</p>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item name="cloud">
|
||||
<template #title>
|
||||
<span class="collapse-title-wrap">
|
||||
Cloud 资源与发货明细
|
||||
<span class="collapse-badge">
|
||||
{{ badgeLabel(resolveCloudBadge(flow)) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div>
|
||||
<span>账号优先级</span>
|
||||
<strong>{{ formatCloudSourcePriority(flow) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>实际使用账号</span>
|
||||
<strong>{{ formatResolvedCloudSource(flow) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>cloud SKU</span>
|
||||
<strong>{{
|
||||
flow.binding.skuId ? `${flow.binding.skuId} / ${flow.binding.skuName || '-'}` : '-'
|
||||
}}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>VN Key</span><strong>{{ flow.binding.vnKey || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动购买</span
|
||||
><strong>{{ flow.purchase.autoBuyEnabled ? '开启' : '关闭' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>背包命中</span><strong>{{ flow.purchase.usedKnapsack ? '是' : '否' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>触发购买</span
|
||||
><strong>{{ flow.purchase.purchaseTriggered ? '是' : '否' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>购买时间</span
|
||||
><strong>{{ formatAdminDateTime(flow.purchase.purchaseAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发货时间</span
|
||||
><strong>{{ formatAdminDateTime(flow.dispatch.dispatchAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送类型</span><strong>{{ flow.dispatch.sendType || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>结果说明</span
|
||||
><strong>{{ flow.dispatch.note || detail.task.resultMessage || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item name="closure">
|
||||
<template #title>
|
||||
<span class="collapse-title-wrap">
|
||||
流程收口
|
||||
<span class="collapse-badge">
|
||||
{{ badgeLabel(resolveClosureBadge(flow)) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div>
|
||||
<span>退号时间</span
|
||||
><strong>{{ formatAdminDateTime(flow.returnNumber.returnedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动退号</span
|
||||
><strong>{{ flow.returnNumber.autoReturnEnabled ? '开启' : '关闭' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>自动核销</span
|
||||
><strong>{{ flow.consume.autoConsumeEnabled ? '开启' : '关闭' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>核销店铺</span><strong>{{ flow.consume.shopId || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>核销店铺名</span><strong>{{ flow.consume.shopName || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>核销时间</span><strong>{{ formatAdminDateTime(flow.consume.consumedAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>核销异常</span><strong>{{ flow.consume.errorMessage || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>备注</span><strong>{{ flow.notes || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item v-if="roleInfoEntries.length > 0" name="raw" title="角色原始字段">
|
||||
<div class="flow-meta-list flow-meta-list--two-column">
|
||||
<div v-for="entry in roleInfoEntries" :key="`raw-${entry.key}`">
|
||||
<span>{{ entry.label }}</span>
|
||||
<strong>{{ entry.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item v-if="flow.rebind?.history?.length" name="rebind" title="换绑记录">
|
||||
<div class="event-timeline compact-event-timeline">
|
||||
<div
|
||||
v-for="item in flow.rebind.history"
|
||||
:key="`rebind-${item.attempt}-${item.requestedAt}`"
|
||||
class="event-tl-item"
|
||||
>
|
||||
<div class="event-tl-gutter">
|
||||
<div class="event-tl-dot"></div>
|
||||
</div>
|
||||
<div class="event-tl-body">
|
||||
<div class="event-tl-type">
|
||||
第 {{ item.attempt }} 次换绑 · {{ formatRebindSource(item.source) }} ·
|
||||
{{ item.status === 'success' ? '成功' : '失败' }}
|
||||
</div>
|
||||
<div class="event-tl-payload">原 {{ formatRebindBinding(item.oldBinding) }}</div>
|
||||
<div v-if="item.newBinding" class="event-tl-payload">
|
||||
新 {{ formatRebindBinding(item.newBinding) }}
|
||||
</div>
|
||||
<div v-if="item.errorMessage" class="event-tl-payload">
|
||||
{{ item.errorMessage }}
|
||||
</div>
|
||||
<div class="event-tl-time">{{ formatAdminDateTime(item.requestedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<style scoped src="../AdminTaskDetail.css"></style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user