重命名前端目录

This commit is contained in:
yml2213
2026-07-07 19:55:18 +08:00
parent 8268a3b906
commit dbcb5a4979
182 changed files with 7019 additions and 7017 deletions
-1
View File
@@ -1 +0,0 @@
VITE_API_TARGET=http://127.0.0.1:3000
-1
View File
@@ -1 +0,0 @@
VITE_API_TARGET=http://127.0.0.1:3000
-25
View File
@@ -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/
-4
View File
@@ -1,4 +0,0 @@
dist
node_modules
src/auto-imports.d.ts
src/components.d.ts
-6
View File
@@ -1,6 +0,0 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "all"
}
-3
View File
@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}
-32
View File
@@ -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
```
-34
View File
@@ -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 }],
},
},
)
+3 -4
View File
@@ -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>
+1139 -2662
View File
File diff suppressed because it is too large Load Diff
+20 -29
View File
@@ -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

+90
View File
@@ -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>
)
}
-10
View File
@@ -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>
-78
View File
@@ -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')
}
-61
View File
@@ -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 }
}
+192
View File
@@ -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 '客服'
}
+62 -43
View File
@@ -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')
},
})
})
}
-13
View File
@@ -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')
+50
View File
@@ -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>,
)
+20
View File
@@ -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
-113
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
-76
View File
@@ -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;
}
-48
View File
@@ -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>
@@ -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>
@@ -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;
}
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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">查询券码后自动回填 eTicketIdoidformToken再执行核销</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>
@@ -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>
@@ -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">成功查询后会自动回填 eTicketIdoidformToken 和建议核销数量</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">这里保留接口原始响应方便核对 resulterror_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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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
}
@@ -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,
}
}
@@ -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,
}
}
@@ -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,
}
}
@@ -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,
}
}
@@ -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,
}
}
@@ -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