删除 vue

This commit is contained in:
yml2213
2026-07-09 17:27:50 +08:00
parent 4d5b0d6fc6
commit 1bfb32dcd0
144 changed files with 0 additions and 24913 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 }],
},
},
)
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-4211
View File
File diff suppressed because it is too large Load Diff
-41
View File
@@ -1,41 +0,0 @@
{
"name": "order-site-rewrite",
"private": true,
"version": "0.0.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 ."
},
"dependencies": {
"@types/qrcode": "^1.5.6",
"axios": "^1.9.0",
"element-plus": "^2.10.2",
"qrcode": "^1.5.4",
"vue": "^3.5.30",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.9.1",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"sass": "^1.99.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.59.3",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.0.0",
"vite": "^8.0.7",
"vue-tsc": "^3.2.5"
}
}
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

-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>
@@ -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 }
}
@@ -1,59 +0,0 @@
export const TASK_STATUS = {
PENDING_PAYMENT: "pending_payment",
PAID: "paid",
LINK_GENERATED: "link_generated",
CLAIMED: "claimed",
PENDING_BINDING_PREPARE: "pending_binding_prepare",
WAITING_BINDING: "waiting_binding",
ROLE_CONFIRMED: "role_confirmed",
REDEEMING: "redeeming",
DISPATCHED_PENDING_RETURN: "dispatched_pending_return",
COMPLETED: "completed",
REDEEMED: "redeemed",
RETRY_PENDING: "retry_pending",
MANUAL_REVIEW: "manual_review",
FAILED: "failed",
EXPIRED: "expired",
CLOSED: "closed",
} as const;
export type KnownTaskStatus = (typeof TASK_STATUS)[keyof typeof TASK_STATUS];
export type TaskStatus = KnownTaskStatus | (string & {});
const CLAIM_INACTIVE_STATUSES = new Set<TaskStatus>([
TASK_STATUS.COMPLETED,
TASK_STATUS.REDEEMED,
TASK_STATUS.MANUAL_REVIEW,
TASK_STATUS.FAILED,
TASK_STATUS.EXPIRED,
TASK_STATUS.CLOSED,
]);
const KUAISHOU_CLOUD_RESULT_STATUSES = new Set<TaskStatus>([
TASK_STATUS.DISPATCHED_PENDING_RETURN,
TASK_STATUS.COMPLETED,
TASK_STATUS.REDEEMED,
TASK_STATUS.MANUAL_REVIEW,
TASK_STATUS.FAILED,
]);
export function normalizeTaskStatus(value: unknown): TaskStatus {
return String(value || "").trim() as TaskStatus;
}
export function isClaimInactiveTaskStatus(status: unknown): boolean {
return CLAIM_INACTIVE_STATUSES.has(normalizeTaskStatus(status));
}
export function isKuaishouCloudRoleConfirmedStatus(status: unknown): boolean {
return normalizeTaskStatus(status) === TASK_STATUS.ROLE_CONFIRMED;
}
export function isKuaishouCloudCompletedStatus(status: unknown): boolean {
const normalized = normalizeTaskStatus(status);
return normalized === TASK_STATUS.COMPLETED || normalized === TASK_STATUS.REDEEMED;
}
export function hasKuaishouCloudRedeemResultStatus(status: unknown): boolean {
return KUAISHOU_CLOUD_RESULT_STATUSES.has(normalizeTaskStatus(status));
}
-67
View File
@@ -1,67 +0,0 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import type { ElMessageBoxOptions, MessageOptions } from 'element-plus'
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,
}
const baseConfirmOptions: ElMessageBoxOptions = {
type: 'warning',
confirmButtonText: '继续执行',
cancelButtonText: '取消',
closeOnClickModal: false,
closeOnPressEscape: false,
autofocus: false,
distinguishCancelAndClose: true,
}
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 showError(message: string, options: Partial<MessageOptions> = {}) {
return ElMessage.error({
...baseMessageOptions,
...options,
message,
})
}
export function showConfirm(
message: string,
title = '确认操作',
options: ElMessageBoxOptions = {},
) {
return ElMessageBox.confirm(message, title, {
...baseConfirmOptions,
...options,
})
}
export function showPrompt(message: string, title: string, options: ElMessageBoxOptions = {}) {
return ElMessageBox.prompt(message, title, {
...basePromptOptions,
...options,
})
}
export function isFeedbackDismissed(error: unknown) {
return error === 'cancel' || error === 'close'
}
-136
View File
@@ -1,136 +0,0 @@
import axios from 'axios'
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
export interface ApiEnvelope<T> {
code: number
msg: string
data: T
errorCode?: string
}
const http = axios.create({
baseURL: '/',
timeout: 50_000,
headers: {
'Content-Type': 'application/json',
},
})
http.interceptors.response.use(
(response) => {
const data = response.data as ApiEnvelope<unknown>
// Business-level error: HTTP 200 but code !== 0
if (typeof data?.code === 'number' && data.code !== 0) {
const error = new Error(data.msg || '操作失败') as Error & {
errorCode?: string
code?: number
}
error.errorCode = data.errorCode
error.code = data.code
return Promise.reject(error)
}
return response.data
},
(error) => {
const responseMessage =
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
const fallbackMessage = resolveFallbackHttpMessage(error)
const normalizedError = new Error(responseMessage || fallbackMessage) as Error & {
errorCode?: string
status?: number
}
if (error?.response?.data?.errorCode) {
normalizedError.errorCode = String(error.response.data.errorCode)
}
if (typeof error?.response?.status === 'number') {
normalizedError.status = error.response.status
}
if (error?.config?.url && String(error.config.url).startsWith('/api/v1/admin')) {
const status = Number(error?.response?.status || 0)
const errorCode = String(error?.response?.data?.errorCode || '').trim()
if (status === 401 && shouldClearAdminSession(errorCode)) {
clearAdminSession()
if (
window.location.hash.startsWith('#/admin') &&
!window.location.hash.startsWith('#/admin/login')
) {
window.location.hash = '#/admin/login'
}
}
}
return Promise.reject(normalizedError)
},
)
http.interceptors.request.use((config) => {
if (String(config.url || '').startsWith('/api/v1/admin')) {
const token = getAdminToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
}
return config
})
function resolveFallbackHttpMessage(error: unknown) {
const message = String((error as { message?: string })?.message ?? '')
if (message.includes('timeout')) {
return '网络超时'
}
if (message === 'Network Error') {
return '网络连接错误'
}
if (typeof (error as { response?: { statusText?: string } })?.response?.statusText === 'string') {
return String((error as { response: { statusText: string } }).response.statusText).trim()
}
return '接口请求失败'
}
function shouldClearAdminSession(errorCode: string) {
return [
'admin_auth_required',
'admin_auth_invalid',
'admin_auth_expired',
'admin_auth_user_invalid',
'admin_auth_stale',
].includes(errorCode)
}
function request<T>(config: Parameters<typeof http.request<ApiEnvelope<T>>>[0]) {
return http.request<ApiEnvelope<T>, ApiEnvelope<T>>(config)
}
export function apiGet<T>(url: string, params?: Record<string, unknown>) {
return request<T>({ method: 'GET', url, params })
}
export function apiGetBlob(url: string, params?: Record<string, unknown>) {
return http.request<Blob, Blob>({
method: 'GET',
url,
params,
responseType: 'blob',
})
}
export function apiPost<T>(url: string, data?: unknown) {
return request<T>({ method: 'POST', url, data: data as Record<string, unknown> })
}
export function apiDelete<T>(url: string) {
return request<T>({ method: 'DELETE', url })
}
-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')
-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,9 +0,0 @@
import { apiGet } from '@/lib/http'
import type { AdminAuditLogItem, AdminPagination } from '@/types/admin'
export function fetchAdminAuditLogs(params?: Record<string, unknown>) {
return apiGet<{ items: AdminAuditLogItem[]; pagination: AdminPagination }>(
'/api/v1/admin/audit-logs',
params,
)
}
@@ -1,14 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminLoginResponse, AdminSessionSummary } from '@/types/admin'
export function loginAdmin(payload: { username: string; password: string }) {
return apiPost<AdminLoginResponse>('/api/v1/admin/auth/login', payload)
}
export function fetchAdminSession() {
return apiGet<AdminSessionSummary>('/api/v1/admin/auth/session')
}
export function logoutAdmin() {
return apiPost<{ success: boolean }>('/api/v1/admin/auth/logout', {})
}
@@ -1,6 +0,0 @@
import { apiGet } from '@/lib/http'
import type { AdminDashboardSummary } from '@/types/admin'
export function fetchAdminDashboardSummary() {
return apiGet<AdminDashboardSummary>('/api/v1/admin/dashboard/summary')
}
@@ -1,7 +0,0 @@
export * from './auth'
export * from './dashboard'
export * from './users'
export * from './audit-logs'
export * from './platform-config'
export * from './orders'
export * from './tasks'
@@ -1,22 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminOrderDetail, AdminOrderListItem, AdminPagination } from '@/types/admin'
export function fetchAdminOrders(params?: Record<string, unknown>) {
return apiGet<{ items: AdminOrderListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/orders',
params,
)
}
export function fetchAdminOrderDetail(orderId: number | string) {
return apiGet<AdminOrderDetail>(`/api/v1/admin/orders/${orderId}`)
}
export function resendAdminOrderKuaishouIndustryVoucherCode(orderId: number | string) {
return apiPost<{
success: boolean
resentCount: number
failedCount: number
errorMessage: string
}>(`/api/v1/admin/orders/${orderId}/kuaishou-industry/resend-code`, {})
}
@@ -1,336 +0,0 @@
import { apiDelete, apiGet, apiPost } from '@/lib/http'
import type {
AdminCloudtentaclesLoginTestResult,
AdminCloudtentaclesDeliveryRecordListResult,
AdminCloudtentaclesOverrideRuleConfig,
AdminCloudtentaclesSkuListResult,
AdminCloudtentaclesSendSmsResult,
AdminCloudtentaclesSourceConfigResponse,
AdminCloudtentaclesSourceItem,
AdminCloudtentaclesSourcesConfig,
AdminCloudtentaclesValidateSessionResult,
} from '@/types/admin'
export function fetchAdminCloudtentaclesSourceConfig() {
return apiGet<AdminCloudtentaclesSourceConfigResponse>(
'/api/v1/admin/platform-config/cloudtentacles-source',
)
}
export function saveAdminCloudtentaclesSourceConfig(
payload:
| AdminCloudtentaclesSourcesConfig
| ({ sourceKey: string } & Partial<AdminCloudtentaclesSourceItem>),
) {
return apiPost<AdminCloudtentaclesSourceConfigResponse>(
'/api/v1/admin/platform-config/cloudtentacles-source',
payload,
)
}
export function deleteAdminCloudtentaclesSource(sourceKey: string) {
return apiDelete<{ success: boolean }>(
`/api/v1/admin/platform-config/cloudtentacles-source/${sourceKey}`,
)
}
export function fetchAdminCloudtentaclesOverrideRules() {
return apiGet<AdminCloudtentaclesOverrideRuleConfig>(
'/api/v1/admin/platform-config/cloudtentacles/override-rules',
)
}
export function saveAdminCloudtentaclesOverrideRules(
payload: {
enabled: boolean
rules: Array<{
id?: string
enabled?: boolean
productName?: string
sourceKey?: string
deliveryItems?: Array<{
cloudSkuId?: number
cloudSkuName?: string
quantity?: number
}>
notes?: string
}>
},
) {
return apiPost<AdminCloudtentaclesOverrideRuleConfig>(
'/api/v1/admin/platform-config/cloudtentacles/override-rules',
payload,
)
}
export function sendAdminCloudtentaclesSmsCode(payload: {
baseUrl?: string
username?: string
phone?: string
deviceId?: string
deviceType?: number
sourceKey?: string
}) {
return apiPost<AdminCloudtentaclesSendSmsResult>(
'/api/v1/admin/platform-config/cloudtentacles/send-sms-code',
payload,
)
}
export function testAdminCloudtentaclesLogin(payload: {
baseUrl?: string
username?: string
password?: string
phone?: string
code?: string
deviceId?: string
deviceType?: number
sourceKey?: string
}) {
return apiPost<AdminCloudtentaclesLoginTestResult>(
'/api/v1/admin/platform-config/cloudtentacles/test-login',
payload,
)
}
export function validateAdminCloudtentaclesSession(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
sourceKey?: string
}) {
return apiPost<AdminCloudtentaclesValidateSessionResult>(
'/api/v1/admin/platform-config/cloudtentacles/validate-session',
payload,
)
}
export function fetchAdminCloudtentaclesAsset(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/asset',
payload,
)
}
export function fetchAdminCloudtentaclesCategories(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/categories',
payload,
)
}
export function fetchAdminCloudtentaclesSkuList(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
sourceKey?: string
}) {
return apiPost<AdminCloudtentaclesSkuListResult>(
'/api/v1/admin/platform-config/cloudtentacles/sku/list',
payload,
)
}
export function buyAdminCloudtentaclesSku(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
id?: number
count?: number
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/sku/buy',
payload,
)
}
export function useAdminCloudtentaclesSku(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
id?: number
virtualNumberId?: number
phone?: string
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/sku/use',
payload,
)
}
export function fetchAdminCloudtentaclesKnapsack(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/knapsack',
payload,
)
}
export function fetchAdminCloudtentaclesDeliveryRecords(payload: {
sourceKey?: string
page?: number
size?: number
startDate?: string
endDate?: string
platformOrderId?: string
}) {
return apiPost<AdminCloudtentaclesDeliveryRecordListResult>(
'/api/v1/admin/cloudtentacles-records',
payload,
)
}
export function fetchAdminCloudtentaclesRecordSources() {
return apiGet<{
sources: Array<{
key: string
label: string
enabled: boolean
hasToken: boolean
loggedInAt: string
}>
}>('/api/v1/admin/cloudtentacles-records/sources')
}
export function fetchAdminCloudtentaclesVnList(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
key?: string
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/list',
payload,
)
}
export function appointAdminCloudtentaclesVn(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
key?: string
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/appoint',
payload,
)
}
export function generateAdminCloudtentaclesVnLoginCode(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
key?: string
id?: number
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/generate-login-code',
payload,
)
}
export function fetchAdminCloudtentaclesVnCode(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
key?: string
phone?: string
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/fetch-code',
payload,
)
}
export function verifyAdminCloudtentaclesVnCode(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
key?: string
id?: number
code?: string
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/verify-code',
payload,
)
}
export function fetchAdminCloudtentaclesBindUrl(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
key?: string
id?: number
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/bind-url',
payload,
)
}
export function backAdminCloudtentaclesVn(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
key?: string
id?: number
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/vn/back',
payload,
)
}
export function runAdminCloudtentaclesFullFlow(payload: {
baseUrl?: string
token?: string
deviceId?: string
deviceType?: number
skuId?: number
skuCount?: number
vnKey?: string
sourceKey?: string
}) {
return apiPost<Record<string, unknown>>(
'/api/v1/admin/platform-config/cloudtentacles/debug/full-flow',
payload,
)
}
@@ -1,6 +0,0 @@
export * from './notifications'
export * from './scheduled-jobs'
export * from './ninetyone'
export * from './kuaishou-eticket'
export * from './kuaishou-feifei'
export * from './cloudtentacles'
@@ -1,60 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminKuaishouEticketConsumeResult,
AdminKuaishouEticketDetailResult,
AdminKuaishouEticketShopInfoResult,
AdminKuaishouEticketSourceConfig,
} from '@/types/admin'
export function fetchAdminKuaishouEticketSourceConfig() {
return apiGet<{
filePath: string
source: AdminKuaishouEticketSourceConfig
}>('/api/v1/admin/platform-config/kuaishou-eticket-source')
}
export function saveAdminKuaishouEticketSourceConfig(payload: AdminKuaishouEticketSourceConfig) {
return apiPost<{
filePath: string
source: AdminKuaishouEticketSourceConfig
}>('/api/v1/admin/platform-config/kuaishou-eticket-source', payload)
}
export function queryAdminKuaishouEticketDetail(payload: {
baseUrl?: string
shopId?: string
cookie?: string
eTicketId: string
}) {
return apiPost<AdminKuaishouEticketDetailResult>(
'/api/v1/admin/platform-config/kuaishou-eticket/query-detail',
payload,
)
}
export function queryAdminKuaishouEticketShopInfo(payload: {
baseUrl?: string
shopId?: string
cookie?: string
}) {
return apiPost<AdminKuaishouEticketShopInfoResult>(
'/api/v1/admin/platform-config/kuaishou-eticket/query-shop-info',
payload,
)
}
export function consumeAdminKuaishouEticket(payload: {
baseUrl?: string
shopId?: string
cookie?: string
eTicketId: string
oid?: string
formToken?: string
num?: number
storeId?: string
}) {
return apiPost<AdminKuaishouEticketConsumeResult>(
'/api/v1/admin/platform-config/kuaishou-eticket/consume',
payload,
)
}
@@ -1,79 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminKuaishouFeifeiConfig,
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiMatchResult,
AdminKuaishouFeifeiOrderResult,
AdminKuaishouFeifeiProductListResult,
AdminKuaishouFeifeiProductSyncResult,
} from '@/types/admin'
export function fetchAdminKuaishouFeifeiConfig() {
return apiGet<AdminKuaishouFeifeiConfigResponse>(
'/api/v1/admin/platform-config/kuaishou-feifei',
)
}
export function saveAdminKuaishouFeifeiConfig(payload: AdminKuaishouFeifeiConfig) {
return apiPost<AdminKuaishouFeifeiConfigResponse>(
'/api/v1/admin/platform-config/kuaishou-feifei',
payload,
)
}
export function matchAdminKuaishouFeifeiProduct(productName: string) {
return apiPost<AdminKuaishouFeifeiMatchResult>(
'/api/v1/admin/platform-config/kuaishou-feifei/match',
{ productName },
)
}
export function fetchAdminKuaishouFeifeiProducts(payload: {
page?: number
perPage?: number
status?: string
supplyProductName?: string
}) {
return apiPost<AdminKuaishouFeifeiProductListResult>(
'/api/v1/admin/platform-config/kuaishou-feifei/products',
payload,
)
}
export function syncAdminKuaishouFeifeiProducts(payload: {
status?: string
supplyProductName?: string
} = {}) {
return apiPost<AdminKuaishouFeifeiProductSyncResult>(
'/api/v1/admin/platform-config/kuaishou-feifei/sync-products',
payload,
)
}
export function createAdminKuaishouFeifeiTestOrder(payload: {
platformOrderNo: string
productCode: string
platformBuyNum?: number
platformAmount?: number | null
playerAccount?: string
playerGameRegion?: string
playerGameSrv?: string
playerGameRole?: string
submitPlayer?: boolean
notifyUrl?: string
}) {
return apiPost<AdminKuaishouFeifeiOrderResult>(
'/api/v1/admin/platform-config/kuaishou-feifei/test-order',
payload,
)
}
export function queryAdminKuaishouFeifeiOrder(payload: {
platformOrderNo?: string
orderNo?: string
}) {
return apiPost<AdminKuaishouFeifeiOrderResult>(
'/api/v1/admin/platform-config/kuaishou-feifei/query-order',
payload,
)
}
@@ -1,42 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
AdminNinetyoneOrderActionResult,
AdminNinetyoneOrderListResult,
} from '@/types/admin'
export function fetchAdminNinetyoneOrders(
params: {
page?: number
pageSize?: number
status?: string
} = {},
) {
const searchParams = new URLSearchParams()
if (params.page) {
searchParams.set('page', String(params.page))
}
if (params.pageSize) {
searchParams.set('pageSize', String(params.pageSize))
}
if (params.status) {
searchParams.set('status', params.status)
}
const queryString = searchParams.toString()
return apiGet<AdminNinetyoneOrderListResult>(
`/api/v1/admin/platform-config/ninetyone/orders${queryString ? `?${queryString}` : ''}`,
)
}
export function retryAdminNinetyoneOrder(orderId: number | string) {
return apiPost<AdminNinetyoneOrderActionResult>(
`/api/v1/admin/platform-config/ninetyone/orders/${orderId}/retry`,
{},
)
}
export function failAdminNinetyoneOrder(orderId: number | string, payload: { reason?: string }) {
return apiPost<AdminNinetyoneOrderActionResult>(
`/api/v1/admin/platform-config/ninetyone/orders/${orderId}/fail`,
payload,
)
}
@@ -1,23 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminNotificationConfig, AdminNotificationTestResult } from '@/types/admin'
export function fetchAdminNotificationConfig() {
return apiGet<{
filePath: string
source: AdminNotificationConfig
}>('/api/v1/admin/platform-config/notifications')
}
export function saveAdminNotificationConfig(payload: AdminNotificationConfig) {
return apiPost<{
filePath: string
source: AdminNotificationConfig
}>('/api/v1/admin/platform-config/notifications', payload)
}
export function testAdminNotification(payload: { title?: string; body?: string; url?: string }) {
return apiPost<AdminNotificationTestResult>(
'/api/v1/admin/platform-config/notifications/test',
payload,
)
}
@@ -1,20 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminScheduledJobsConfig, AdminScheduledJobsResponse } from '@/types/admin'
export function fetchAdminScheduledJobsConfig() {
return apiGet<AdminScheduledJobsResponse>('/api/v1/admin/platform-config/scheduled-jobs')
}
export function saveAdminScheduledJobsConfig(payload: AdminScheduledJobsConfig) {
return apiPost<AdminScheduledJobsResponse>(
'/api/v1/admin/platform-config/scheduled-jobs',
payload,
)
}
export function runAdminScheduledJob(jobId: string) {
return apiPost<{
result: Record<string, unknown>
runtime: AdminScheduledJobsResponse['runtime']
}>(`/api/v1/admin/platform-config/scheduled-jobs/${jobId}/run`, {})
}
@@ -1,116 +0,0 @@
import { apiGet, apiGetBlob, apiPost } from '@/lib/http'
import type {
AdminPagination,
AdminTaskActionResponse,
AdminTaskDetail,
AdminTaskListItem,
} from '@/types/admin'
export function fetchAdminTasks(params?: Record<string, unknown>) {
return apiGet<{ items: AdminTaskListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/tasks',
params,
)
}
export function fetchAdminTaskDetail(taskId: number | string) {
return apiGet<AdminTaskDetail>(`/api/v1/admin/tasks/${taskId}`)
}
export function fetchAdminTaskScreenshot(taskId: number | string) {
return apiGetBlob(`/api/v1/admin/tasks/${taskId}/screenshot`)
}
export function retryAdminTask(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/retry`, {})
}
export function regenerateAdminTaskClaimLink(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/regenerate-claim-link`, {})
}
export function closeAdminTask(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/close`, {})
}
export function markAdminTaskManualReview(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(`/api/v1/admin/tasks/${taskId}/mark-manual-review`, {})
}
export function completeAdminTaskManualDispatch(
taskId: number | string,
payload: {
outcome: 'delivered' | 'failed'
resultMessage?: string
deliveryReference?: string
deliveredCredential?: string
},
) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/complete-manual-dispatch`,
payload,
)
}
export function prepareAdminTaskKuaishouCloudFulfillment(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/prepare`,
{},
)
}
export function refreshAdminTaskKuaishouCloudRoleInfo(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/refresh-role-info`,
{},
)
}
export function rebindAdminTaskKuaishouCloudRole(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/rebind-role`,
{},
)
}
export function dispatchAdminTaskKuaishouCloudFulfillment(
taskId: number | string,
payload: {
ticketCode?: string
} = {},
) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/dispatch`,
payload,
)
}
export function returnNumberAdminTaskKuaishouCloudFulfillment(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-cloud/return-number`,
{},
)
}
export function resendAdminTaskKuaishouIndustryVoucherCode(taskId: number | string) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-industry/resend-code`,
{},
)
}
export function consumeAdminTaskKuaishouIndustryVoucher(
taskId: number | string,
payload: {
consumeType?: string
storeName?: string
storeAddress?: string
expressCode?: string
expressNo?: string
} = {},
) {
return apiPost<AdminTaskActionResponse>(
`/api/v1/admin/tasks/${taskId}/kuaishou-industry/consume`,
payload,
)
}
@@ -1,33 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type { AdminPagination, AdminUserListItem } from '@/types/admin'
export function fetchAdminUsers(params?: Record<string, unknown>) {
return apiGet<{ items: AdminUserListItem[]; pagination: AdminPagination }>(
'/api/v1/admin/users',
params,
)
}
export function createAdminUser(payload: {
username: string
password: string
role: string
status?: string
}) {
return apiPost<{ user: AdminUserListItem }>('/api/v1/admin/users', payload)
}
export function updateAdminUserRole(userId: number | string, payload: { role: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/role`, payload)
}
export function updateAdminUserStatus(userId: number | string, payload: { status: string }) {
return apiPost<{ user: AdminUserListItem }>(`/api/v1/admin/users/${userId}/status`, payload)
}
export function resetAdminUserPassword(userId: number | string, payload: { password: string }) {
return apiPost<{ user: AdminUserListItem }>(
`/api/v1/admin/users/${userId}/reset-password`,
payload,
)
}
-27
View File
@@ -1,27 +0,0 @@
import { apiGet, apiPost } from '@/lib/http'
import type { ClaimDetailData } from '@/types/claim'
export function fetchClaimDetail(token: string) {
return apiGet<ClaimDetailData>(`/api/v1/claim/${token}`)
}
export function verifyKuaishouCloudClaimTicket(
token: string,
payload: {
ticketCode: string
},
) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/verify-ticket`, payload)
}
export function confirmKuaishouCloudClaimRole(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {})
}
export function rebindKuaishouCloudClaimRole(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/rebind-role`, {})
}
export function redeemKuaishouCloudClaim(token: string) {
return apiPost<ClaimDetailData>(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {})
}
@@ -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;
}
-57
View File
@@ -1,57 +0,0 @@
:root {
color: var(--text-primary);
background:
radial-gradient(circle at top left, rgba(44, 133, 247, 0.22), transparent 32%),
radial-gradient(circle at top right, rgba(114, 184, 255, 0.18), transparent 26%),
linear-gradient(180deg, var(--bg-page) 0%, #f7fbff 100%);
font-family: var(--font-sans);
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
min-height: 100%;
}
body {
margin: 0;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(20, 47, 95, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(20, 47, 95, 0.02) 1px, transparent 1px);
background-size: 24px 24px;
mask-image: radial-gradient(circle at center, black, transparent 75%);
}
img {
max-width: 100%;
}
button,
input,
select,
textarea {
font: inherit;
}
a {
color: inherit;
}
-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;
}
@@ -1,13 +0,0 @@
import type { AdminRole } from './auth'
export interface AdminAuditLogItem {
logId: number
actorUserId: number
actorUsername: string
actorRole: AdminRole
action: string
targetType: string
targetId: string
payload: Record<string, unknown>
createdAt: string
}
-22
View File
@@ -1,22 +0,0 @@
export type AdminRole = 'admin' | 'operator' | 'support'
export type AdminUserStatus = 'active' | 'disabled'
export interface AdminLoginResponse {
token: string
expiresAt: string
user: {
userId: number
username: string
role: AdminRole
}
}
export interface AdminSessionSummary {
authenticated: boolean
expiresAt: string
user: {
userId: number
username: string
role: AdminRole
}
}
@@ -1,5 +0,0 @@
export interface AdminPagination {
page: number
pageSize: number
total: number
}
@@ -1,7 +0,0 @@
export interface AdminDashboardSummary {
todayOrders: number
paidPendingClaim: number
claimingTasks: number
redeemedToday: number
abnormalTasks: number
}
@@ -1,81 +0,0 @@
// Common types
export type { AdminPagination } from './common'
// Auth types
export type { AdminRole, AdminUserStatus, AdminLoginResponse, AdminSessionSummary } from './auth'
// Users types
export type { AdminUserListItem } from './users'
// Audit logs types
export type { AdminAuditLogItem } from './audit-logs'
// Dashboard types
export type { AdminDashboardSummary } from './dashboard'
// Orders types
export type {
AdminOrderFulfillmentProgress,
AdminOrderListItem,
AdminOrderDetail,
} from './orders'
// Tasks types
export type {
AdminTaskListItem,
AdminTaskOperations,
AdminTaskActionResponse,
AdminTaskDetail,
} from './tasks'
// Platform config types
export type {
AdminNotificationBarkRecipient,
AdminNotificationWpushRecipient,
AdminNotificationConfig,
AdminNotificationSendResult,
AdminNotificationTestResult,
AdminCloudtentaclesMonitorAccount,
AdminScheduledJobAccountRuntime,
AdminScheduledJobCloudtentaclesAccount,
AdminScheduledJobItem,
AdminScheduledJobsConfig,
AdminScheduledJobRuntimeState,
AdminScheduledJobsResponse,
AdminNinetyoneOrderItem,
AdminNinetyoneOrderListResult,
AdminNinetyoneOrderActionResult,
AdminKuaishouEticketSourceConfig,
AdminKuaishouEticketShopConfigItem,
AdminKuaishouEticketShopInfoResult,
AdminKuaishouEticketDetailResult,
AdminKuaishouEticketConsumeResult,
AdminKuaishouFeifeiProductRule,
AdminKuaishouFeifeiConfig,
AdminKuaishouFeifeiEffectiveConfig,
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiProductSyncResult,
AdminKuaishouFeifeiProductMatch,
AdminKuaishouFeifeiMatchResult,
AdminKuaishouFeifeiProductItem,
AdminKuaishouFeifeiProductListResult,
AdminKuaishouFeifeiOrderResult,
AdminCloudtentaclesSourceItem,
AdminCloudtentaclesSourcesConfig,
AdminCloudtentaclesSourceConfigResponse,
AdminCloudtentaclesOverrideDeliveryItem,
AdminCloudtentaclesOverrideRule,
AdminCloudtentaclesOverrideRuleConfig,
AdminCloudtentaclesSessionsMap,
AdminCloudtentaclesSourceConfig,
AdminCloudtentaclesPersistedSession,
AdminCloudtentaclesSessionItem,
AdminCloudtentaclesSendSmsResult,
AdminCloudtentaclesSessionSummary,
AdminCloudtentaclesLoginTestResult,
AdminCloudtentaclesValidateSessionResult,
AdminCloudtentaclesSkuItem,
AdminCloudtentaclesSkuListResult,
AdminCloudtentaclesDeliveryRecordItem,
AdminCloudtentaclesDeliveryRecordListResult,
} from './platform-config'
@@ -1,87 +0,0 @@
import type { AdminTaskListItem } from './tasks'
export interface AdminOrderFulfillmentProgress {
totalTaskCount: number
preparedTaskCount: number
completedTaskCount: number
resourceStatus: string
customerStatus: string
}
export interface AdminOrderListItem {
orderId: number
provider: string
platform: string
shopId: string
shopName: string
platformOrderId: string
orderStatus: string
payStatus: string
buyerId: string
buyerName: string
receiverContact: string
totalAmount: string
totalAmountFen: number
currency: string
paidAt: string | null
createdAt: string
updatedAt: string
itemCount: number
totalQuantity: number
itemSummary: string
taskCount: number
resourceStatus: string
customerStatus: string
preparedTaskCount: number
completedTaskCount: number
}
export interface AdminOrderDetail {
order: {
orderId: number
provider: string
platform: string
shopId: string
shopName: string
platformOrderId: string
orderStatus: string
payStatus: string
buyerId: string
buyerName: string
receiverContact: string
totalAmount: string
totalAmountFen: number
currency: string
paidAt: string | null
createdAt: string
updatedAt: string
itemSummary: string
rawPayload: Record<string, unknown>
fulfillmentProgress: AdminOrderFulfillmentProgress
}
items: Array<{
orderItemId: number
skuCode: string
skuName: string
itemTitle: string
quantity: number
deliveryMode: string
spec: Record<string, unknown>
}>
tasks: AdminTaskListItem[]
kuaishouIndustryVouchers: Array<{
oid: string
voucherCode: string
unitIndex: number
status: string
sendCallbackStatus: string
sendCallbackAttemptCount: number
sendCallbackLastError: string
sendCallbackSentAt: string | null
consumedAt: string | null
destroyedAt: string | null
}>
operations: {
canResendKuaishouIndustryVoucherCode: boolean
}
}
@@ -1,181 +0,0 @@
export interface AdminCloudtentaclesSourceItem {
key: string
label: string
enabled: boolean
baseUrl: string
username: string
password: string
phone: string
deviceId: string
deviceType: number
}
export interface AdminCloudtentaclesSourcesConfig {
enabled: boolean
sources: AdminCloudtentaclesSourceItem[]
}
export interface AdminCloudtentaclesPersistedSession {
token: string
tokenMasked: string
baseUrl: string
username: string
phoneMasked: string
loggedInAt: string
deviceId: string
deviceType: number
hasToken: boolean
}
export type AdminCloudtentaclesSessionItem = AdminCloudtentaclesPersistedSession
export interface AdminCloudtentaclesSessionsMap {
[sourceKey: string]: AdminCloudtentaclesPersistedSession
}
export interface AdminCloudtentaclesSourceConfigResponse {
filePath: string
sessionFilePath: string
enabled: boolean
sources: AdminCloudtentaclesSourceItem[]
sessions: AdminCloudtentaclesSessionsMap
}
export interface AdminCloudtentaclesOverrideDeliveryItem {
cloudSkuId: number
cloudSkuName: string
quantity: number
}
export interface AdminCloudtentaclesOverrideRule {
id: string
enabled: boolean
productName: string
normalizedProductName: string
sourceKey: string
deliveryItems: AdminCloudtentaclesOverrideDeliveryItem[]
notes: string
}
export interface AdminCloudtentaclesOverrideRuleConfig {
filePath: string
enabled: boolean
rules: AdminCloudtentaclesOverrideRule[]
}
export interface AdminCloudtentaclesSendSmsResult {
baseUrl: string
username: string
phone: string
phoneMasked: string
sentAt: string
responseMessage: string
}
export interface AdminCloudtentaclesSessionSummary {
tokenMasked: string
permissionCount: number
permissions: string[]
persisted?: boolean
}
export interface AdminCloudtentaclesLoginTestResult {
baseUrl: string
username: string
phoneMasked: string
loggedInAt: string
responseMessage: string
token: string
session: AdminCloudtentaclesSessionSummary
userInfo: Record<string, unknown>
asset: number
}
export interface AdminCloudtentaclesValidateSessionResult {
baseUrl: string
loggedInAt: string
session: AdminCloudtentaclesSessionSummary
userInfo: Record<string, unknown>
asset: number
}
export interface AdminCloudtentaclesSkuItem {
id: number
categoriesId: number
name: string
description: string
image: string
inventory: number
price: number
listingTime: string
delistingTime: string
buyLimitMin: number
buyLimitMax: number
raw: Record<string, unknown>
}
export interface AdminCloudtentaclesSkuListResult {
baseUrl: string
itemCount: number
items: AdminCloudtentaclesSkuItem[]
rawItems: Record<string, unknown>[]
}
export interface AdminCloudtentaclesDeliveryRecordItem {
createdAt: string
recordId: string
virtualNumberId: number
userId: number
count: number
cdk: string
exchangeUrl: string
name: string
skuId: number
skuName: string
skuImage: string
phone: string
status: number
fulfillUser: string
operatorName: string
platformOrderId: string
taskId: number
taskNo: string
orderShopId: string
orderShopName: string
consumeShopId: string
consumeShopName: string
roleName: string
roleId: string
localTaskStatus: string
localDeliveryStatus: string
localDispatchStatus: string
localDispatchAt: string
localResultCode: string
localResultMessage: string
matchConfidence: string
raw: Record<string, unknown>
}
export interface AdminCloudtentaclesDeliveryRecordListResult {
baseUrl: string
page: number
size: number
total: number
sourceKey?: string
queryStartDate?: string
queryEndDate?: string
localOrderMatched?: boolean
items: AdminCloudtentaclesDeliveryRecordItem[]
raw: Record<string, unknown>
}
/** @deprecated Use AdminCloudtentaclesSourceItem + AdminCloudtentaclesSourcesConfig instead */
export interface AdminCloudtentaclesSourceConfig {
enabled: boolean
baseUrl: string
username: string
password: string
phone: string
deviceId: string
deviceType: number
}
@@ -1,65 +0,0 @@
export type {
AdminNotificationBarkRecipient,
AdminNotificationWpushRecipient,
AdminNotificationConfig,
AdminNotificationSendResult,
AdminNotificationTestResult,
} from './notifications'
export type {
AdminCloudtentaclesMonitorAccount,
AdminScheduledJobAccountRuntime,
AdminScheduledJobCloudtentaclesAccount,
AdminScheduledJobItem,
AdminScheduledJobsConfig,
AdminScheduledJobRuntimeState,
AdminScheduledJobsResponse,
} from './scheduled-jobs'
export type {
AdminNinetyoneOrderItem,
AdminNinetyoneOrderListResult,
AdminNinetyoneOrderActionResult,
} from './ninetyone'
export type {
AdminKuaishouEticketSourceConfig,
AdminKuaishouEticketShopConfigItem,
AdminKuaishouEticketShopInfoResult,
AdminKuaishouEticketDetailResult,
AdminKuaishouEticketConsumeResult,
} from './kuaishou-eticket'
export type {
AdminKuaishouFeifeiProductRule,
AdminKuaishouFeifeiConfig,
AdminKuaishouFeifeiEffectiveConfig,
AdminKuaishouFeifeiConfigResponse,
AdminKuaishouFeifeiProductSyncResult,
AdminKuaishouFeifeiProductMatch,
AdminKuaishouFeifeiMatchResult,
AdminKuaishouFeifeiProductItem,
AdminKuaishouFeifeiProductListResult,
AdminKuaishouFeifeiOrderResult,
} from './kuaishou-feifei'
export type {
AdminCloudtentaclesSourceItem,
AdminCloudtentaclesSourcesConfig,
AdminCloudtentaclesSourceConfigResponse,
AdminCloudtentaclesOverrideDeliveryItem,
AdminCloudtentaclesOverrideRule,
AdminCloudtentaclesOverrideRuleConfig,
AdminCloudtentaclesSessionsMap,
AdminCloudtentaclesSourceConfig,
AdminCloudtentaclesPersistedSession,
AdminCloudtentaclesSessionItem,
AdminCloudtentaclesSendSmsResult,
AdminCloudtentaclesSessionSummary,
AdminCloudtentaclesLoginTestResult,
AdminCloudtentaclesValidateSessionResult,
AdminCloudtentaclesSkuItem,
AdminCloudtentaclesSkuListResult,
AdminCloudtentaclesDeliveryRecordItem,
AdminCloudtentaclesDeliveryRecordListResult,
} from './cloudtentacles'
@@ -1,83 +0,0 @@
export interface AdminKuaishouEticketShopConfigItem {
shopId: string
kshopName: string
cookie: string
cookieMasked: string
hasCookie: boolean
userAvatar: string
enabled: boolean
}
export interface AdminKuaishouEticketSourceConfig {
enabled: boolean
baseUrl: string
shops: AdminKuaishouEticketShopConfigItem[]
}
export interface AdminKuaishouEticketShopInfoResult {
baseUrl: string
ok: boolean
result: number
errorMessage: string
requestId: string
shop: {
shopId: string
kshopName: string
userAvatar: string
settleStatus: number
hasCookie: boolean
}
raw: Record<string, unknown>
}
export interface AdminKuaishouEticketDetailResult {
baseUrl: string
eTicketId: string
ok: boolean
alreadyConsumed: boolean
result: number
errorMessage: string
requestId: string
serverTimestamp: string
detail: {
uid: string
fulfillDetailId: string
sellerId: string
formToken: string
validEndTime: string
validStartTime: string
leftReverseCount: number
eTicketId: string
oid: string
totalCount: number
leftCount: number
status: string
} | null
goods: {
itemId: string
itemPicUrl: string
itemTitle: string
price: string
skuDesc: string
skuId: string
} | null
raw: Record<string, unknown>
}
export interface AdminKuaishouEticketConsumeResult {
baseUrl: string
eTicketId: string
oid: string
formToken: string
num: number
storeId: string
ok: boolean
consumed: boolean
alreadyConsumed: boolean
result: number
errorMessage: string
requestId: string
serverTimestamp: string
detail: AdminKuaishouEticketDetailResult['detail']
raw: Record<string, unknown>
}
@@ -1,104 +0,0 @@
export interface AdminKuaishouFeifeiProductRule {
id: string
enabled: boolean
productName: string
normalizedProductName: string
productCode: string
skuName: string
notes: string
}
export interface AdminKuaishouFeifeiConfig {
enabled: boolean
baseUrl: string
appKey: string
appSecret: string
timeoutMs: number
notifyUrl: string
productRules: AdminKuaishouFeifeiProductRule[]
}
export interface AdminKuaishouFeifeiEffectiveConfig {
enabled: boolean
baseUrl: string
timeoutMs: number
notifyUrl: string
hasAppKey: boolean
hasAppSecret: boolean
productRuleCount: number
}
export interface AdminKuaishouFeifeiConfigResponse {
filePath: string
source: AdminKuaishouFeifeiConfig
effective: AdminKuaishouFeifeiEffectiveConfig
}
export interface AdminKuaishouFeifeiProductSyncResult extends AdminKuaishouFeifeiConfigResponse {
sync: {
productCount: number
ruleCount: number
status: string
supplyProductName: string
}
}
export interface AdminKuaishouFeifeiProductMatch {
matchMode: string
productName: string
normalizedProductName: string
productCode: string
skuName: string
}
export interface AdminKuaishouFeifeiMatchResult {
productName: string
normalizedProductName: string
matched: boolean
match: AdminKuaishouFeifeiProductMatch | null
}
export interface AdminKuaishouFeifeiProductItem {
productCode: string
name: string
channel: string
externalProductId: string
imageUrl: string
status: string
supplyStatus: string
supplyPricePoints: number
feePoints: number
unitCostPoints: number
maxOrderQuantity: number
salePricePoints: number | null
raw: Record<string, unknown>
}
export interface AdminKuaishouFeifeiProductListResult {
items: AdminKuaishouFeifeiProductItem[]
total: number
page: number
perPage: number
raw: Record<string, unknown>
}
export interface AdminKuaishouFeifeiOrderResult {
orderNo: string
platformOrderNo: string
productCode: string
productName: string
rechargeStatus: number
rechargeStatusLabel: string
pointsCharged: number
playerAccount: string
platformBuyNum: number
rechargeResultMessage: string
createdAt: string
updatedAt: string
rechargeFinishAt: string
h5: {
entryUrl: string
rechargeUrl: string
}
raw: Record<string, unknown>
}
@@ -1,32 +0,0 @@
export interface AdminNinetyoneOrderItem {
orderId: number
orderNo: string
outTradeNo: string
orderStatus: string
payStatus: string
shopId: string
shopName: string
productNo: string
productName: string
buyNum: number
taskCount: number
failReason: string
createdAt: string
updatedAt: string
items: Array<Record<string, unknown>>
}
export interface AdminNinetyoneOrderListResult {
page: number
pageSize: number
total: number
items: AdminNinetyoneOrderItem[]
}
export interface AdminNinetyoneOrderActionResult {
orderId: number
orderNo: string
orderStatus: string
orderItemCount: number
taskCount: number
}
@@ -1,50 +0,0 @@
export interface AdminNotificationBarkRecipient {
id: string
name: string
deviceKey: string
deviceKeyMasked: string
enabled: boolean
}
export interface AdminNotificationWpushRecipient {
id: string
name: string
apiKey: string
apiKeyMasked: string
enabled: boolean
}
export interface AdminNotificationConfig {
enabled: boolean
channels: {
bark: {
enabled: boolean
serverUrl: string
recipients: AdminNotificationBarkRecipient[]
}
wpush: {
enabled: boolean
recipients: AdminNotificationWpushRecipient[]
}
}
}
export interface AdminNotificationSendResult {
channel: string
recipientId: string
recipientName: string
recipientKeyMasked: string
ok: boolean
status: number
errorMessage: string
response: unknown
}
export interface AdminNotificationTestResult {
enabled: boolean
channel: string
successCount: number
failedCount: number
skippedCount: number
results: AdminNotificationSendResult[]
}
@@ -1,74 +0,0 @@
export interface AdminScheduledJobCloudtentaclesAccount {
sourceKey: string
label: string
enabled: boolean
assetThreshold: number
}
export interface AdminScheduledJobItem {
id: string
type: string
enabled: boolean
intervalSeconds: number
cooldownSeconds: number
config: {
assetThreshold: number
accounts: AdminScheduledJobCloudtentaclesAccount[]
}
}
export interface AdminScheduledJobsConfig {
enabled: boolean
jobs: AdminScheduledJobItem[]
}
export interface AdminScheduledJobRuntimeState {
id: string
enabled: boolean
running: boolean
lastRunAt: string
lastFinishedAt: string
nextRunAt: string
lastStatus: string
lastMessage: string
lastAsset: number | null
lastThreshold: number | null
lastAccounts: AdminScheduledJobAccountRuntime[]
lastAccountCount: number
lastCheckedCount: number
lastOkCount: number
lastLowAssetCount: number
lastFailedCount: number
lastManual: boolean
}
export interface AdminScheduledJobAccountRuntime {
sourceKey: string
label: string
enabled: boolean
hasToken: boolean
ok: boolean
skipped: boolean
status: string
message: string
asset: number | null
threshold: number
checkedAt: string
}
export interface AdminCloudtentaclesMonitorAccount {
sourceKey: string
label: string
enabled: boolean
username: string
phoneMasked: string
hasToken: boolean
loggedInAt: string
}
export interface AdminScheduledJobsResponse {
filePath: string
source: AdminScheduledJobsConfig
runtime: AdminScheduledJobRuntimeState[]
cloudtentaclesAccounts: AdminCloudtentaclesMonitorAccount[]
}
-239
View File
@@ -1,239 +0,0 @@
export interface AdminTaskListItem {
taskId: number
taskNo: string
platformOrderId: string
skuCode: string
skuName: string
status: string
executorKey: string
deliveryStatus: string
resultCode: string
resultMessage: string
resourceStatus: string
customerStatus: string
loginType: string
roleName: string
roleId: string
runtimeSessionId: string
claimedAt: string | null
roleConfirmedAt: string | null
redeemedAt: string | null
retryCount: number
lastError: string
createdAt: string
updatedAt: string
claimToken: string
screenshotPath: string
}
export interface AdminTaskOperations {
canRetry: boolean
canRegenerateClaimLink: boolean
canClose: boolean
canMarkManualReview: boolean
canCompleteManualDispatch: boolean
canPrepareKuaishouCloudFulfillment: boolean
canRefreshKuaishouCloudRoleInfo: boolean
canRebindKuaishouCloudRole: boolean
canDispatchKuaishouCloudFulfillment: boolean
canReturnKuaishouCloudFulfillment: boolean
canResendKuaishouIndustryVoucherCode: boolean
canConsumeKuaishouIndustryVoucher: boolean
canViewSensitiveTaskData: boolean
}
export interface AdminTaskActionResponse {
task: {
taskId: number
taskNo: string
status: string
deliveryStatus: string
resultCode: string
resultMessage: string
primaryClaimTokenId: number | null
lastError: string
updatedAt: string
}
claimUrl?: string
token?: string
}
export interface AdminTaskDetail {
task: AdminTaskListItem
order: null | {
orderId: number
provider: string
platform: string
shopId: string
shopName: string
platformOrderId: string
payStatus: string
orderStatus: string
}
orderItem: null | {
orderItemId: number
skuCode: string
skuName: string
quantity: number
}
claimToken: null | {
primaryClaimTokenId: number
token: string
status: string
expiredAt: string
claimUrl: string
}
artifacts: Record<string, unknown>
screenshotUrl: string
review: {
required: boolean
screenshotCapturedAt: string | null
roleId: string
roleName: string
}
redeemResolution: null | {
status: string
taskStatus: string
replacementCount: number
finishedAt: string | null
attempts: Array<{
attempt: number
codeMasked: string
credentialType: string
outcome: string
resultCode: string
resultMessage: string
}>
}
kuaishouCloudFulfillment: null | {
flowType: string
configId: string
internalSkuCode: string
internalSkuName: string
ticket: {
code: string
capturedAt: string | null
status: string
verifiedAt: string | null
goodsTitle: string
leftCount: number
}
binding: {
prepareStatus: string
cloudSourceKeys: string[]
cloudSourceLabels: string[]
resolvedSourceKey: string
resolvedSourceLabel: string
skuId: number
skuName: string
vnKey: string
vnId: number
vnPhone: string
bindUrl: string
bindPreparedAt: string | null
bindExpiresAt: string | null
bindProbeAt: string | null
bindProbeStatus: string
bindProbeMessage: string
}
role: {
status: string
name: string
rid: string
refreshedAt: string | null
errorMessage: string
rawInfo: Record<string, unknown> | null
}
purchase: {
autoBuyEnabled: boolean
minAssetReserve: number
usedKnapsack: boolean
purchaseTriggered: boolean
assetBefore: number
assetAfter: number
purchaseAt: string | null
}
dispatch: {
status: string
dispatchAt: string | null
sendType: number
note: string
}
returnNumber: {
status: string
returnedAt: string | null
autoReturnEnabled: boolean
}
consume: {
status: string
shopId: string
shopName: string
autoConsumeEnabled: boolean
consumedAt: string | null
errorMessage: string
}
rebind: {
currentAttempt: number
history: Array<{
attempt: number
source: string
requestedAt: string | null
requestedBy: Record<string, unknown> | null
status: string
errorMessage: string
oldBinding: {
vnKey: string
vnId: number
vnPhone: string
bindUrl: string
bindPreparedAt: string | null
bindExpiresAt: string | null
roleName: string
roleId: string
}
newBinding: null | {
vnKey: string
vnId: number
vnPhone: string
bindUrl: string
bindPreparedAt: string | null
bindExpiresAt: string | null
}
}>
}
notes: string
}
kuaishouIndustryVoucher: null | {
oid: string
voucherCode: string
unitIndex: number
status: string
sendCallbackStatus: string
sendCallbackLastError: string
sendCallbackAttemptCount: number
sendCallbackSentAt: string | null
consumeSerialNum: string
consumeDetails: Array<Record<string, unknown>>
consumedAt: string | null
destroyedAt: string | null
}
manualDispatch: null | {
outcome: string
deliveryReference: string
deliveredCredential: string
resultMessage: string
completedAt: string | null
completedBy: null | {
userId: number
username: string
role: string
}
}
events: Array<{
eventId: number
eventType: string
payload: Record<string, unknown>
createdAt: string
}>
operations: AdminTaskOperations
}
@@ -1,10 +0,0 @@
import type { AdminRole, AdminUserStatus } from './auth'
export interface AdminUserListItem {
userId: number
username: string
role: AdminRole
status: AdminUserStatus
createdAt: string
updatedAt: string
}
-168
View File
@@ -1,168 +0,0 @@
import type { KnownTaskStatus } from '@/domain/task-status'
export type ClaimTokenStatus = 'active' | 'used' | 'expired' | 'revoked' | (string & {})
export type ClaimTaskStatus = KnownTaskStatus | (string & {})
export interface ClaimTaskInfo {
taskId: number
taskNo: string
status: ClaimTaskStatus
executorKey: string
requiresSupportReview: boolean
expiresAt: string | null
claimedAt: string | null
roleConfirmedAt: string | null
redeemedAt: string | null
loginType: string
lastError: string
runtimeSessionId: string
}
export interface ClaimOrderInfo {
orderId: number
platform: string
platformOrderId: string
payStatus: string
orderStatus: string
totalAmount: string
totalAmountFen: number
currency: string
}
export interface ClaimOrderItemInfo {
orderItemId: number
skuCode: string
skuName: string
quantity: number
}
export interface ClaimProductInfo {
title: string
skuCode: string
quantity: number
isBundle: boolean
items: Array<{
cloudSkuId: number
name: string
quantity: number
}>
}
export interface ClaimResultInfo {
resultCode: string
resultMessage: string
screenshotReady: boolean
screenshotUrl: string
}
export interface ClaimKuaishouCloudFlowInfo {
flowType: 'kuaishou_cloud'
shopId: string
shopName: string
guideImages: string[]
ticket: {
code: string
status: string
capturedAt: string | null
verifiedAt: string | null
goodsTitle: string
leftCount: number
}
binding: {
prepareStatus: string
cloudSourceKeys: string[]
resolvedSourceKey: string
skuId: number
skuName: string
vnKey: string
vnId: number
bindUrl: string
bindPreparedAt: string | null
bindExpiresAt: string | null
bindProbeAt: string | null
bindProbeStatus: string
bindProbeMessage: string
vnPhone: string
roleName: string
roleId: string
}
role: {
status: string
name: string
rid: string
refreshedAt: string | null
errorMessage: string
defaultName: string
defaultRid: string
defaultCapturedAt: string | null
defaultCaptureStatus: string
defaultErrorMessage: string
isDefaultRole: boolean
}
purchase: {
autoBuyEnabled: boolean
minAssetReserve: number
usedKnapsack: boolean
purchaseTriggered: boolean
assetBefore: number
assetAfter: number
purchaseAt: string | null
}
dispatch: {
status: string
dispatchAt: string | null
failedAt: string | null
failedStage: string
errorCode: string
errorMessage: string
note: string
}
returnNumber: {
status: string
returnedAt: string | null
}
consume: {
status: string
shopId: string
shopName: string
consumedAt: string | null
errorMessage: string
}
rebind?: {
currentAttempt: number
history: Array<Record<string, unknown>>
}
}
export interface ClaimKuaishouFeifeiFlowInfo {
flowType: 'kuaishou_feifei'
productCode: string
productName: string
platformOrderNo: string
orderNo: string
rechargeStatus: number
rechargeStatusLabel: string
rechargeResultMessage: string
claimUrl: string
consumeStatus: string
h5: {
entryUrl: string
rechargeUrl: string
}
lastSyncedAt: string | null
}
export interface ClaimDetailData {
tokenStatus: ClaimTokenStatus
claimUrl: string
flowType: 'kuaishou_cloud' | 'kuaishou_ct_assisted' | 'kuaishou_feifei' | (string & {})
task: ClaimTaskInfo
order: ClaimOrderInfo
orderItem: ClaimOrderItemInfo
product: ClaimProductInfo
session: unknown | null
kuaishouCloudFulfillment: ClaimKuaishouCloudFlowInfo | null
kuaishouFeifei: ClaimKuaishouFeifeiFlowInfo | null
result: ClaimResultInfo | null
}
-99
View File
@@ -1,99 +0,0 @@
const ADMIN_TOKEN_KEY = 'order-site-admin-token'
const ADMIN_EXPIRES_AT_KEY = 'order-site-admin-token-expires-at'
const ADMIN_USER_ID_KEY = 'order-site-admin-user-id'
const ADMIN_USERNAME_KEY = 'order-site-admin-username'
const ADMIN_ROLE_KEY = 'order-site-admin-role'
const ADMIN_ROLE_LEVEL: Record<'support' | 'operator' | 'admin', number> = {
support: 1,
operator: 2,
admin: 3,
}
export function getAdminToken() {
return localStorage.getItem(ADMIN_TOKEN_KEY) || ''
}
export function getAdminTokenExpiresAt() {
return localStorage.getItem(ADMIN_EXPIRES_AT_KEY) || ''
}
export function getAdminUserId() {
const value = Number(localStorage.getItem(ADMIN_USER_ID_KEY) || 0)
return Number.isFinite(value) && value > 0 ? value : 0
}
export function getAdminUsername() {
return localStorage.getItem(ADMIN_USERNAME_KEY) || ''
}
export function getAdminRole() {
const role = localStorage.getItem(ADMIN_ROLE_KEY)
if (role === 'admin' || role === 'operator' || role === 'support') {
return role
}
return 'support'
}
export function hasAdminRole(role: 'admin' | 'operator' | 'support') {
if (!hasAdminSession()) {
return false
}
return ADMIN_ROLE_LEVEL[getAdminRole()] >= ADMIN_ROLE_LEVEL[role]
}
export function setAdminSession(
token: string,
expiresAt: string,
user?: { userId: number; username: string; role: 'admin' | 'operator' | 'support' },
) {
localStorage.setItem(ADMIN_TOKEN_KEY, token)
localStorage.setItem(ADMIN_EXPIRES_AT_KEY, expiresAt)
if (user?.userId) {
localStorage.setItem(ADMIN_USER_ID_KEY, String(user.userId))
}
if (user?.username) {
localStorage.setItem(ADMIN_USERNAME_KEY, user.username)
}
if (user?.role) {
localStorage.setItem(ADMIN_ROLE_KEY, user.role)
}
}
export function clearAdminSession() {
localStorage.removeItem(ADMIN_TOKEN_KEY)
localStorage.removeItem(ADMIN_EXPIRES_AT_KEY)
localStorage.removeItem(ADMIN_USER_ID_KEY)
localStorage.removeItem(ADMIN_USERNAME_KEY)
localStorage.removeItem(ADMIN_ROLE_KEY)
}
export function hasAdminSession() {
const token = getAdminToken()
const expiresAt = getAdminTokenExpiresAt()
if (!token) {
return false
}
if (isExpired(expiresAt)) {
clearAdminSession()
return false
}
return true
}
function isExpired(expiresAt: string) {
if (!expiresAt) {
return false
}
const expiresAtMs = Date.parse(expiresAt)
return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now()
}
@@ -1,135 +0,0 @@
export function formatStatusWithRaw(status: string, labelMap: Record<string, string> = {}) {
const normalized = String(status || '').trim()
if (!normalized) {
return '-'
}
const key = normalized.toLowerCase()
const label = labelMap[key]
if (!label || label === normalized) {
return normalized
}
return `${label} (${normalized})`
}
export function formatAuditAction(action: string) {
const labelMap: Record<string, string> = {
admin_user_created: '创建后台用户',
admin_user_role_updated: '修改用户角色',
admin_user_status_updated: '修改用户状态',
admin_user_password_reset: '重置用户密码',
task_regenerate_claim_link: '重发领取链接',
task_kuaishou_cloud_rebind_role: '换绑角色',
task_closed: '关闭任务',
task_mark_manual_review: '转人工处理',
task_complete_manual_dispatch: '完成人工履约',
platform_shop_config_updated: '更新店铺配置',
platform_fulfillment_config_updated: '更新履约配置',
}
return formatStatusWithRaw(action, labelMap)
}
export function formatAuditTargetType(targetType: string) {
const labelMap: Record<string, string> = {
admin_user: '后台用户',
task: '交付任务',
platform_config: '平台配置',
}
return formatStatusWithRaw(targetType, labelMap)
}
export function formatTaskEventType(eventType: string) {
const labelMap: Record<string, string> = {
manual_dispatch_completed: '人工履约已回写',
claim_redeem_code_used: '兑换码已使用',
claim_redeem_code_invalid: '兑换码错误',
claim_redeem_completed: '兑换链路完成',
kuaishou_cloud_rebind_requested: '发起角色换绑',
kuaishou_cloud_rebind_old_number_returned: '换绑旧号已退还',
kuaishou_cloud_rebind_prepared: '换绑资源已准备',
kuaishou_cloud_rebind_failed: '换绑失败',
}
return formatStatusWithRaw(eventType, labelMap)
}
export function formatRedeemOutcomeLabel(value: string) {
const normalized = String(value || '').trim()
switch (normalized) {
case 'success':
return '兑换成功'
case 'code_used':
return '兑换码已使用'
case 'code_invalid':
return '兑换码错误'
case 'captcha_rejected':
return '验证码错误'
case 'failed':
return '兑换失败'
default:
return normalized || '-'
}
}
export function formatRedeemResolutionStatus(value: string) {
return value === 'success' ? '已完成' : value === 'failed' ? '失败收口' : value || '-'
}
export function formatKuaishouRoleInfoLabel(value: string) {
const normalized = String(value || '').trim()
switch (normalized) {
case 'name':
return '角色名称'
case 'rid':
return '角色 ID'
default:
return normalized || '-'
}
}
export function formatTaskEventPayload(payload: Record<string, unknown>) {
const parts = [
payload.outcome ? `结果 ${payload.outcome}` : '',
payload.resultCode ? `代码 ${payload.resultCode}` : '',
payload.resultMessage ? `说明 ${payload.resultMessage}` : '',
payload.codeMasked ? `凭据 ${payload.codeMasked}` : '',
payload.previousCodeMasked ? `旧码 ${payload.previousCodeMasked}` : '',
payload.nextCodeMasked ? `新码 ${payload.nextCodeMasked}` : '',
payload.ticketCodeMasked ? `卡券号 ${payload.ticketCodeMasked}` : '',
payload.vnId ? `虚拟号 #${payload.vnId}` : '',
payload.oldVnId ? `旧虚拟号 #${payload.oldVnId}` : '',
payload.vnPhoneMasked ? `号码 ${payload.vnPhoneMasked}` : '',
payload.oldVnPhoneMasked ? `旧号码 ${payload.oldVnPhoneMasked}` : '',
payload.oldRoleName ? `旧角色 ${payload.oldRoleName}` : '',
payload.oldRoleId ? `旧角色ID ${payload.oldRoleId}` : '',
payload.attempt ? `${payload.attempt}` : '',
payload.bindUrl ? `绑定链接 ${payload.bindUrl}` : '',
payload.sendType ? `发送类型 ${payload.sendType}` : '',
payload.note ? `备注 ${payload.note}` : '',
payload.previousOutcome
? `切换原因 ${formatRedeemOutcomeLabel(String(payload.previousOutcome || ''))}`
: '',
payload.deliveryReference ? `单号 ${payload.deliveryReference}` : '',
payload.platformOrderId ? `平台单 ${payload.platformOrderId}` : '',
payload.trigger ? `触发 ${payload.trigger}` : '',
payload.reason ? `原因 ${payload.reason}` : '',
payload.purchaseTriggered === true ? '已触发购买' : '',
payload.usedKnapsack === true ? '使用背包库存' : '',
payload.responseStatus ? `HTTP ${payload.responseStatus}` : '',
payload.requestId ? `请求 ${payload.requestId}` : '',
payload.errorMessage ? `错误 ${payload.errorMessage}` : '',
].filter(Boolean)
if (parts.length > 0) {
return parts.join(' · ')
}
return JSON.stringify(payload || {})
}
@@ -1,52 +0,0 @@
export const adminPayStatusOptions = [
{ label: '全部', value: '' },
{ label: '未支付', value: 'unpaid' },
{ label: '已支付', value: 'paid' },
{ label: '失败', value: 'failed' },
{ label: '已退款', value: 'refunded' },
]
export const adminTaskStatusOptions = [
{ label: '全部', value: '' },
{ label: '待支付', value: 'pending_payment' },
{ label: '已支付', value: 'paid' },
{ label: '已生成链接', value: 'link_generated' },
{ label: '已创建会话', value: 'claimed' },
{ label: '已确认角色', value: 'role_confirmed' },
{ label: '兑换中', value: 'redeeming' },
{ label: '已兑换', value: 'redeemed' },
{ label: '待重试', value: 'retry_pending' },
{ label: '人工处理', value: 'manual_review' },
{ label: '已过期', value: 'expired' },
{ label: '已关闭', value: 'closed' },
]
export const adminUserRoleOptions = [
{ label: '全部', value: '' },
{ label: '管理员', value: 'admin' },
{ label: '普通运营', value: 'operator' },
{ label: '客服', value: 'support' },
]
export const adminUserStatusOptions = [
{ label: '全部', value: '' },
{ label: '生效中', value: 'active' },
{ label: '已停用', value: 'disabled' },
]
export const adminAuditActionOptions = [
{ label: '全部', value: '' },
{ label: '创建后台用户', value: 'admin_user_created' },
{ label: '修改用户角色', value: 'admin_user_role_updated' },
{ label: '修改用户状态', value: 'admin_user_status_updated' },
{ label: '重置用户密码', value: 'admin_user_password_reset' },
{ label: '换绑角色', value: 'task_kuaishou_cloud_rebind_role' },
{ label: '关闭任务', value: 'task_closed' },
{ label: '转人工处理', value: 'task_mark_manual_review' },
]
export const adminAuditTargetTypeOptions = [
{ label: '全部', value: '' },
{ label: '后台用户', value: 'admin_user' },
{ label: '交付任务', value: 'task' },
]
@@ -1 +0,0 @@
export { formatDateTime as formatAdminDateTime } from '@/utils/date-time'
-54
View File
@@ -1,54 +0,0 @@
const ISO_DATE_TIME_PATTERN =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/
export function isIsoDateTimeString(value: unknown): value is string {
return typeof value === 'string' && ISO_DATE_TIME_PATTERN.test(value.trim())
}
export function formatDateTime(value: string | null | undefined, fallback = '-') {
const normalized = String(value || '').trim()
if (!normalized) {
return fallback
}
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) {
return normalized.replace('T', ' ').replace('Z', '')
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
export function normalizeDateTimeValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => normalizeDateTimeValue(item))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, currentValue]) => [
key,
normalizeDateTimeValue(currentValue),
]),
)
}
if (isIsoDateTimeString(value)) {
return formatDateTime(value, '')
}
return value
}
export function stringifyDisplayJson(value: unknown, space = 2) {
return JSON.stringify(normalizeDateTimeValue(value), null, space)
}
@@ -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,487 +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, resendAdminOrderKuaishouIndustryVoucherCode } from '@/services/admin'
import type { AdminOrderDetail } from '@/types/admin'
import { showConfirm, showError, showSuccess } from '@/lib/feedback'
import { formatAdminDateTime } from '@/utils/admin-time'
const route = useRoute()
const loading = ref(true)
const actionLoading = ref(false)
const errorMessage = ref('')
const detail = ref<AdminOrderDetail | null>(null)
type TagType = 'success' | 'primary' | 'warning' | 'danger' | 'info'
async function loadDetail() {
loading.value = true
try {
const response = await fetchAdminOrderDetail(String(route.params.orderId || ''))
detail.value = response.data
errorMessage.value = ''
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '读取订单详情失败'
} finally {
loading.value = false
}
}
onMounted(loadDetail)
async function handleResendKuaishouIndustryCode() {
if (!detail.value) return
try {
await showConfirm(
`确认重发订单 ${detail.value.order.platformOrderId} 的电子凭证发码回调吗?系统会复用当前核销码。`,
'确认操作',
{
type: 'warning',
confirmButtonText: '继续执行',
cancelButtonText: '取消',
},
)
} catch {
return
}
actionLoading.value = true
try {
const response = await resendAdminOrderKuaishouIndustryVoucherCode(detail.value.order.orderId)
showSuccess(`电子凭证发码回调已重发,共 ${response.data.resentCount}`)
await loadDetail()
} catch (error) {
showError(error instanceof Error ? error.message : '重发电子凭证发码回调失败')
await loadDetail()
} finally {
actionLoading.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 v-if="detail.kuaishouIndustryVouchers.length > 0" shadow="never" class="section-card">
<div class="section-headline">
<h3>电子凭证</h3>
<el-button
v-if="detail.operations.canResendKuaishouIndustryVoucherCode"
:loading="actionLoading"
type="primary"
plain
@click="handleResendKuaishouIndustryCode"
>
重发发码回调
</el-button>
</div>
<el-table :data="detail.kuaishouIndustryVouchers" size="small">
<el-table-column prop="voucherCode" label="核销码" min-width="190" />
<el-table-column prop="status" label="券状态" width="110" />
<el-table-column prop="sendCallbackStatus" label="发码回调" width="120" />
<el-table-column label="回调时间" width="180">
<template #default="{ row }">{{ formatAdminDateTime(row.sendCallbackSentAt) }}</template>
</el-table-column>
<el-table-column prop="sendCallbackAttemptCount" label="次数" width="80" />
<el-table-column label="异常">
<template #default="{ row }">{{ row.sendCallbackLastError || '-' }}</template>
</el-table-column>
</el-table>
</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);
}
.section-headline {
display: flex;
justify-content: space-between;
gap: var(--space-3);
align-items: center;
margin-bottom: var(--space-4);
}
.section-headline h3 {
margin: 0;
}
.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>

Some files were not shown because too many files have changed in this diff Show More