first commit

This commit is contained in:
2025-11-24 16:06:44 +08:00
commit e57d497751
165 changed files with 59349 additions and 0 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL="https://api.tianyuanapi.com"

410
.eslintrc-auto-import.json Normal file
View File

@@ -0,0 +1,410 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PERMISSIONS": true,
"PropType": true,
"ROLES": true,
"ROLE_PERMISSIONS": true,
"Ref": true,
"Slot": true,
"Slots": true,
"VERSION_CONFIG": true,
"VNode": true,
"VersionChecker": true,
"WritableComputedRef": true,
"acceptHMRUpdate": true,
"applyRenderOptimizations": true,
"asyncComputed": true,
"authEventBus": true,
"autoResetRef": true,
"axios": true,
"camelCase": true,
"capitalize": true,
"checkForUpdates": true,
"checkPermission": true,
"clearLocalVersions": true,
"compareVersions": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"copyToClipboard": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createIntersectionObserver": true,
"createPinia": true,
"createReactiveFn": true,
"createRef": true,
"createReusableTemplate": true,
"createSharedComposable": true,
"createTemplatePromise": true,
"createUnrefFn": true,
"customRef": true,
"dayjs": true,
"debounce": true,
"debouncedRef": true,
"debouncedWatch": true,
"deepClone": true,
"defineAsyncComponent": true,
"defineComponent": true,
"defineStore": true,
"downloadFile": true,
"eagerComputed": true,
"effectScope": true,
"endsWith": true,
"escape": true,
"extendRef": true,
"filter": true,
"find": true,
"findIndex": true,
"formatDate": true,
"formatFileSize": true,
"formatMoney": true,
"formatPhone": true,
"fromNow": true,
"generateUUID": true,
"get": true,
"getActivePinia": true,
"getBrowserInfo": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"getDevicePerformanceLevel": true,
"getLocalVersions": true,
"getUrlParam": true,
"getUserPermissions": true,
"groupBy": true,
"h": true,
"handleError": true,
"handleResponse": true,
"hasAllPermissions": true,
"hasAnyPermission": true,
"hasUserPermission": true,
"ignorableWatch": true,
"includes": true,
"initRenderOptimizations": true,
"inject": true,
"injectLocal": true,
"isAlipay": true,
"isDefined": true,
"isEmpty": true,
"isEqual": true,
"isMobile": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"isWeChat": true,
"kebabCase": true,
"keyBy": true,
"lowerCase": true,
"makeDestructurable": true,
"map": true,
"mapActions": true,
"mapGetters": true,
"mapState": true,
"mapStores": true,
"mapWritableState": true,
"markRaw": true,
"merge": true,
"nextTick": true,
"omit": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onElementRemoval": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"optimizeAnimations": true,
"optimizeImageLoading": true,
"optimizeLayout": true,
"optimizeMemory": true,
"orderBy": true,
"pausableWatch": true,
"permission": true,
"pick": true,
"prefersReducedMotion": true,
"provide": true,
"provideLocal": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"reduce": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"removeUrlParam": true,
"request": true,
"resolveComponent": true,
"resolveRef": true,
"resolveUnref": true,
"role": true,
"saveLocalVersions": true,
"set": true,
"setActivePinia": true,
"setMapStoreSuffix": true,
"setUrlParam": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"snakeCase": true,
"sortBy": true,
"startsWith": true,
"storeToRefs": true,
"supportsBackdropFilter": true,
"supportsHardwareAcceleration": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttle": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"trim": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unescape": true,
"uniq": true,
"uniqBy": true,
"unref": true,
"unrefElement": true,
"until": true,
"upperCase": true,
"useActiveElement": true,
"useAnimate": true,
"useAppStore": true,
"useArrayDifference": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayFindLast": true,
"useArrayIncludes": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useCertification": true,
"useClipboard": true,
"useClipboardItems": true,
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCountdown": true,
"useCounter": true,
"useCounterStore": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useId": true,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLink": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useModel": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"useParentElement": true,
"usePerformanceObserver": true,
"usePermission": true,
"usePointer": true,
"usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePreferredReducedTransparency": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useSSRWidth": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRef": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToNumber": true,
"useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useUserStore": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"validateEmail": true,
"validateIdCard": true,
"validatePhone": true,
"version": true,
"versionChecker": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchDeep": true,
"watchEffect": true,
"watchIgnorable": true,
"watchImmediate": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true
}
}

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

6
.prettierrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

9
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

855
auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,855 @@
/* 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 ElLoading: typeof import('element-plus')['ElLoading']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const ElMessageBox: typeof import('element-plus/es')['ElMessageBox']
const ElNotification: typeof import('element-plus')['ElNotification']
const PERMISSIONS: typeof import('./src/utils/permission.js')['PERMISSIONS']
const ROLES: typeof import('./src/utils/permission.js')['ROLES']
const ROLE_PERMISSIONS: typeof import('./src/utils/permission.js')['ROLE_PERMISSIONS']
const VERSION_CONFIG: typeof import('./src/utils/version.js')['VERSION_CONFIG']
const VersionChecker: typeof import('./src/utils/version.js')['VersionChecker']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const applyPerformanceOptimizations: typeof import('./src/utils/performance.js')['applyPerformanceOptimizations']
const applyRenderOptimizations: typeof import('./src/utils/performance.js')['applyRenderOptimizations']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const authEventBus: typeof import('./src/utils/request.js')['authEventBus']
const autoImports: typeof import('./src/utils/auto-imports.js')['autoImports']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const axios: typeof import('axios')['default']
const camelCase: typeof import('lodash-es')['camelCase']
const capitalize: typeof import('lodash-es')['capitalize']
const checkForUpdates: typeof import('./src/utils/version.js')['checkForUpdates']
const checkPermission: typeof import('./src/utils/permission.js')['checkPermission']
const clearLocalVersions: typeof import('./src/utils/version.js')['clearLocalVersions']
const cloneDeep: typeof import('lodash-es')['cloneDeep']
const compareVersions: typeof import('./src/utils/version.js')['compareVersions']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const copyToClipboard: typeof import('./src/utils/index.js')['copyToClipboard']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createIntersectionObserver: typeof import('./src/utils/performance.js')['createIntersectionObserver']
const createPinia: typeof import('pinia')['createPinia']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createRef: typeof import('@vueuse/core')['createRef']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const dayjs: typeof import('dayjs')['default']
const debounce: typeof import('./src/utils/performance.js')['debounce']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const deepClone: typeof import('./src/utils/index.js')['deepClone']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const downloadFile: typeof import('./src/utils/index.js')['downloadFile']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const endsWith: typeof import('lodash-es')['endsWith']
const errorMonitor: typeof import('./src/utils/errorMonitor.js')['default']
const escape: typeof import('lodash-es')['escape']
const exportToCSV: typeof import('./src/utils/export.js')['exportToCSV']
const exportToExcel: typeof import('./src/utils/export.js')['exportToExcel']
const extendRef: typeof import('@vueuse/core')['extendRef']
const filter: typeof import('lodash-es')['filter']
const find: typeof import('lodash-es')['find']
const findIndex: typeof import('lodash-es')['findIndex']
const formatAmount: typeof import('./src/utils/export.js')['formatAmount']
const formatDate: typeof import('./src/utils/index.js')['formatDate']
const formatDateTime: typeof import('./src/utils/export.js')['formatDateTime']
const formatFileSize: typeof import('./src/utils/index.js')['formatFileSize']
const formatMoney: typeof import('./src/utils/index.js')['formatMoney']
const formatPhone: typeof import('./src/utils/index.js')['formatPhone']
const fromNow: typeof import('./src/utils/index.js')['fromNow']
const generateFilename: typeof import('./src/utils/export.js')['generateFilename']
const generateUUID: typeof import('./src/utils/index.js')['generateUUID']
const get: typeof import('lodash-es')['get']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getBrowserInfo: typeof import('./src/utils/index.js')['getBrowserInfo']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getDevicePerformanceLevel: typeof import('./src/utils/performance.js')['getDevicePerformanceLevel']
const getLocalVersions: typeof import('./src/utils/version.js')['getLocalVersions']
const getNetworkQuality: typeof import('./src/utils/performance.js')['getNetworkQuality']
const getUrlParam: typeof import('./src/utils/index.js')['getUrlParam']
const getUserPermissions: typeof import('./src/utils/permission.js')['getUserPermissions']
const groupBy: typeof import('lodash-es')['groupBy']
const h: typeof import('vue')['h']
const handleError: typeof import('./src/utils/request.js')['handleError']
const handleResponse: typeof import('./src/utils/request.js')['handleResponse']
const hasAllPermissions: typeof import('./src/utils/permission.js')['hasAllPermissions']
const hasAnyPermission: typeof import('./src/utils/permission.js')['hasAnyPermission']
const hasUserPermission: typeof import('./src/utils/permission.js')['hasUserPermission']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const includes: typeof import('lodash-es')['includes']
const initPerformanceOptimizations: typeof import('./src/utils/performance.js')['initPerformanceOptimizations']
const initRenderOptimizations: typeof import('./src/utils/performance.js')['initRenderOptimizations']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isAlipay: typeof import('./src/utils/index.js')['isAlipay']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isEmpty: typeof import('lodash-es')['isEmpty']
const isEqual: typeof import('lodash-es')['isEqual']
const isMobile: typeof import('./src/utils/index.js')['isMobile']
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 isWeChat: typeof import('./src/utils/index.js')['isWeChat']
const kebabCase: typeof import('lodash-es')['kebabCase']
const keyBy: typeof import('lodash-es')['keyBy']
const lowerCase: typeof import('lodash-es')['lowerCase']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const map: typeof import('lodash-es')['map']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const merge: typeof import('lodash-es')['merge']
const nextTick: typeof import('vue')['nextTick']
const omit: typeof import('lodash-es')['omit']
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 onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
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 onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const optimizeAnimations: typeof import('./src/utils/performance.js')['optimizeAnimations']
const optimizeImageLoading: typeof import('./src/utils/performance.js')['optimizeImageLoading']
const optimizeLayout: typeof import('./src/utils/performance.js')['optimizeLayout']
const optimizeMemory: typeof import('./src/utils/performance.js')['optimizeMemory']
const optimizeNetwork: typeof import('./src/utils/performance.js')['optimizeNetwork']
const optimizeTransitions: typeof import('./src/utils/performance.js')['optimizeTransitions']
const orderBy: typeof import('lodash-es')['orderBy']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const permission: typeof import('./src/utils/permission.js')['default']
const pick: typeof import('lodash-es')['pick']
const prefersReducedMotion: typeof import('./src/utils/performance.js')['prefersReducedMotion']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const reduce: typeof import('lodash-es')['reduce']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const removeUrlParam: typeof import('./src/utils/index.js')['removeUrlParam']
const request: typeof import('./src/utils/request.js')['default']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const role: typeof import('./src/utils/permission.js')['role']
const saveLocalVersions: typeof import('./src/utils/version.js')['saveLocalVersions']
const set: typeof import('lodash-es')['set']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const setUrlParam: typeof import('./src/utils/index.js')['setUrlParam']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const snakeCase: typeof import('lodash-es')['snakeCase']
const sortBy: typeof import('lodash-es')['sortBy']
const startsWith: typeof import('lodash-es')['startsWith']
const storeToRefs: typeof import('pinia')['storeToRefs']
const supportsBackdropFilter: typeof import('./src/utils/performance.js')['supportsBackdropFilter']
const supportsHardwareAcceleration: typeof import('./src/utils/performance.js')['supportsHardwareAcceleration']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttle: typeof import('./src/utils/performance.js')['throttle']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
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 trim: typeof import('lodash-es')['trim']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unescape: typeof import('lodash-es')['unescape']
const uniq: typeof import('lodash-es')['uniq']
const uniqBy: typeof import('lodash-es')['uniqBy']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const upperCase: typeof import('lodash-es')['upperCase']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useAppStore: typeof import('./src/stores/app.js')['useAppStore']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncData: typeof import('@vueuse/core')['useAsyncData']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBluetoothAvailability: typeof import('@vueuse/core')['useBluetoothAvailability']
const useBluetoothConnect: typeof import('@vueuse/core')['useBluetoothConnect']
const useBluetoothDisconnect: typeof import('@vueuse/core')['useBluetoothDisconnect']
const useBluetoothGetAvailability: typeof import('@vueuse/core')['useBluetoothGetAvailability']
const useBluetoothGetCharacteristic: typeof import('@vueuse/core')['useBluetoothGetCharacteristic']
const useBluetoothGetCharacteristics: typeof import('@vueuse/core')['useBluetoothGetCharacteristics']
const useBluetoothGetDescriptor: typeof import('@vueuse/core')['useBluetoothGetDescriptor']
const useBluetoothGetDescriptors: typeof import('@vueuse/core')['useBluetoothGetDescriptors']
const useBluetoothGetDevices: typeof import('@vueuse/core')['useBluetoothGetDevices']
const useBluetoothGetService: typeof import('@vueuse/core')['useBluetoothGetService']
const useBluetoothGetServices: typeof import('@vueuse/core')['useBluetoothGetServices']
const useBluetoothReadValue: typeof import('@vueuse/core')['useBluetoothReadValue']
const useBluetoothRequestDevice: typeof import('@vueuse/core')['useBluetoothRequestDevice']
const useBluetoothStartNotifications: typeof import('@vueuse/core')['useBluetoothStartNotifications']
const useBluetoothStopNotifications: typeof import('@vueuse/core')['useBluetoothStopNotifications']
const useBluetoothWriteValue: typeof import('@vueuse/core')['useBluetoothWriteValue']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useCertification: typeof import('./src/composables/useCertification.js')['useCertification']
const useClickOutside: typeof import('@vueuse/core')['useClickOutside']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCounterStore: typeof import('./src/stores/counter.js')['useCounterStore']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDoubleClick: typeof import('@vueuse/core')['useDoubleClick']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useDroppable: typeof import('@vueuse/core')['useDroppable']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementFocus: typeof import('@vueuse/core')['useElementFocus']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useGesture: typeof import('@vueuse/core')['useGesture']
const useHotkeys: typeof import('@vueuse/core')['useHotkeys']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyCombo: typeof import('@vueuse/core')['useKeyCombo']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useKeyPressed: typeof import('@vueuse/core')['useKeyPressed']
const useKeyboard: typeof import('@vueuse/core')['useKeyboard']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useLongPress: typeof import('@vueuse/core')['useLongPress']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const usePageVisibility: typeof import('@vueuse/core')['usePageVisibility']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSharedWorker: typeof import('@vueuse/core')['useSharedWorker']
const useSlots: typeof import('vue')['useSlots']
const useSortable: typeof import('@vueuse/core')['useSortable']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useToggleDark: typeof import('@vueuse/core')['useToggleDark']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useUserStore: typeof import('./src/stores/user.js')['useUserStore']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVersionStore: typeof import('./src/stores/version.js')['useVersionStore']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const validateEmail: typeof import('./src/utils/index.js')['validateEmail']
const validateIdCard: typeof import('./src/utils/index.js')['validateIdCard']
const validatePhone: typeof import('./src/utils/index.js')['validatePhone']
const version: typeof import('./src/utils/version.js')['default']
const versionChecker: typeof import('./src/utils/version.js')['versionChecker']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { VersionChecker } from './src/utils/version.js'
import('./src/utils/version.js')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly PERMISSIONS: UnwrapRef<typeof import('./src/utils/permission.js')['PERMISSIONS']>
readonly ROLES: UnwrapRef<typeof import('./src/utils/permission.js')['ROLES']>
readonly ROLE_PERMISSIONS: UnwrapRef<typeof import('./src/utils/permission.js')['ROLE_PERMISSIONS']>
readonly VERSION_CONFIG: UnwrapRef<typeof import('./src/utils/version.js')['VERSION_CONFIG']>
readonly VersionChecker: UnwrapRef<typeof import('./src/utils/version.js')['VersionChecker']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly applyRenderOptimizations: UnwrapRef<typeof import('./src/utils/performance.js')['applyRenderOptimizations']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly authEventBus: UnwrapRef<typeof import('./src/utils/request.js')['authEventBus']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly axios: UnwrapRef<typeof import('axios')['default']>
readonly camelCase: UnwrapRef<typeof import('lodash-es')['camelCase']>
readonly capitalize: UnwrapRef<typeof import('lodash-es')['capitalize']>
readonly checkForUpdates: UnwrapRef<typeof import('./src/utils/version.js')['checkForUpdates']>
readonly checkPermission: UnwrapRef<typeof import('./src/utils/permission.js')['checkPermission']>
readonly clearLocalVersions: UnwrapRef<typeof import('./src/utils/version.js')['clearLocalVersions']>
readonly compareVersions: UnwrapRef<typeof import('./src/utils/version.js')['compareVersions']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly computedAsync: UnwrapRef<typeof import('@vueuse/core')['computedAsync']>
readonly computedEager: UnwrapRef<typeof import('@vueuse/core')['computedEager']>
readonly computedInject: UnwrapRef<typeof import('@vueuse/core')['computedInject']>
readonly computedWithControl: UnwrapRef<typeof import('@vueuse/core')['computedWithControl']>
readonly controlledComputed: UnwrapRef<typeof import('@vueuse/core')['controlledComputed']>
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
readonly copyToClipboard: UnwrapRef<typeof import('./src/utils/index.js')['copyToClipboard']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
readonly createIntersectionObserver: UnwrapRef<typeof import('./src/utils/performance.js')['createIntersectionObserver']>
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
readonly createRef: UnwrapRef<typeof import('@vueuse/core')['createRef']>
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
readonly createTemplatePromise: UnwrapRef<typeof import('@vueuse/core')['createTemplatePromise']>
readonly createUnrefFn: UnwrapRef<typeof import('@vueuse/core')['createUnrefFn']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly dayjs: UnwrapRef<typeof import('dayjs')['default']>
readonly debounce: UnwrapRef<typeof import('./src/utils/performance.js')['debounce']>
readonly debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly deepClone: UnwrapRef<typeof import('./src/utils/index.js')['deepClone']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
readonly downloadFile: UnwrapRef<typeof import('./src/utils/index.js')['downloadFile']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly endsWith: UnwrapRef<typeof import('lodash-es')['endsWith']>
readonly escape: UnwrapRef<typeof import('lodash-es')['escape']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly filter: UnwrapRef<typeof import('lodash-es')['filter']>
readonly find: UnwrapRef<typeof import('lodash-es')['find']>
readonly findIndex: UnwrapRef<typeof import('lodash-es')['findIndex']>
readonly formatDate: UnwrapRef<typeof import('./src/utils/index.js')['formatDate']>
readonly formatFileSize: UnwrapRef<typeof import('./src/utils/index.js')['formatFileSize']>
readonly formatMoney: UnwrapRef<typeof import('./src/utils/index.js')['formatMoney']>
readonly formatPhone: UnwrapRef<typeof import('./src/utils/index.js')['formatPhone']>
readonly fromNow: UnwrapRef<typeof import('./src/utils/index.js')['fromNow']>
readonly generateUUID: UnwrapRef<typeof import('./src/utils/index.js')['generateUUID']>
readonly get: UnwrapRef<typeof import('lodash-es')['get']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getBrowserInfo: UnwrapRef<typeof import('./src/utils/index.js')['getBrowserInfo']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getDevicePerformanceLevel: UnwrapRef<typeof import('./src/utils/performance.js')['getDevicePerformanceLevel']>
readonly getLocalVersions: UnwrapRef<typeof import('./src/utils/version.js')['getLocalVersions']>
readonly getUrlParam: UnwrapRef<typeof import('./src/utils/index.js')['getUrlParam']>
readonly getUserPermissions: UnwrapRef<typeof import('./src/utils/permission.js')['getUserPermissions']>
readonly groupBy: UnwrapRef<typeof import('lodash-es')['groupBy']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly handleError: UnwrapRef<typeof import('./src/utils/request.js')['handleError']>
readonly handleResponse: UnwrapRef<typeof import('./src/utils/request.js')['handleResponse']>
readonly hasAllPermissions: UnwrapRef<typeof import('./src/utils/permission.js')['hasAllPermissions']>
readonly hasAnyPermission: UnwrapRef<typeof import('./src/utils/permission.js')['hasAnyPermission']>
readonly hasUserPermission: UnwrapRef<typeof import('./src/utils/permission.js')['hasUserPermission']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly includes: UnwrapRef<typeof import('lodash-es')['includes']>
readonly initRenderOptimizations: UnwrapRef<typeof import('./src/utils/performance.js')['initRenderOptimizations']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isAlipay: UnwrapRef<typeof import('./src/utils/index.js')['isAlipay']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isEmpty: UnwrapRef<typeof import('lodash-es')['isEmpty']>
readonly isEqual: UnwrapRef<typeof import('lodash-es')['isEqual']>
readonly isMobile: UnwrapRef<typeof import('./src/utils/index.js')['isMobile']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly isWeChat: UnwrapRef<typeof import('./src/utils/index.js')['isWeChat']>
readonly kebabCase: UnwrapRef<typeof import('lodash-es')['kebabCase']>
readonly keyBy: UnwrapRef<typeof import('lodash-es')['keyBy']>
readonly lowerCase: UnwrapRef<typeof import('lodash-es')['lowerCase']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly map: UnwrapRef<typeof import('lodash-es')['map']>
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly merge: UnwrapRef<typeof import('lodash-es')['merge']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly omit: UnwrapRef<typeof import('lodash-es')['omit']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onElementRemoval: UnwrapRef<typeof import('@vueuse/core')['onElementRemoval']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly optimizeAnimations: UnwrapRef<typeof import('./src/utils/performance.js')['optimizeAnimations']>
readonly optimizeImageLoading: UnwrapRef<typeof import('./src/utils/performance.js')['optimizeImageLoading']>
readonly optimizeLayout: UnwrapRef<typeof import('./src/utils/performance.js')['optimizeLayout']>
readonly optimizeMemory: UnwrapRef<typeof import('./src/utils/performance.js')['optimizeMemory']>
readonly orderBy: UnwrapRef<typeof import('lodash-es')['orderBy']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
readonly permission: UnwrapRef<typeof import('./src/utils/permission.js')['default']>
readonly pick: UnwrapRef<typeof import('lodash-es')['pick']>
readonly prefersReducedMotion: UnwrapRef<typeof import('./src/utils/performance.js')['prefersReducedMotion']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly reactiveComputed: UnwrapRef<typeof import('@vueuse/core')['reactiveComputed']>
readonly reactiveOmit: UnwrapRef<typeof import('@vueuse/core')['reactiveOmit']>
readonly reactivePick: UnwrapRef<typeof import('@vueuse/core')['reactivePick']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly reduce: UnwrapRef<typeof import('lodash-es')['reduce']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly refAutoReset: UnwrapRef<typeof import('@vueuse/core')['refAutoReset']>
readonly refDebounced: UnwrapRef<typeof import('@vueuse/core')['refDebounced']>
readonly refDefault: UnwrapRef<typeof import('@vueuse/core')['refDefault']>
readonly refThrottled: UnwrapRef<typeof import('@vueuse/core')['refThrottled']>
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly removeUrlParam: UnwrapRef<typeof import('./src/utils/index.js')['removeUrlParam']>
readonly request: UnwrapRef<typeof import('./src/utils/request.js')['default']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly role: UnwrapRef<typeof import('./src/utils/permission.js')['role']>
readonly saveLocalVersions: UnwrapRef<typeof import('./src/utils/version.js')['saveLocalVersions']>
readonly set: UnwrapRef<typeof import('lodash-es')['set']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly setUrlParam: UnwrapRef<typeof import('./src/utils/index.js')['setUrlParam']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly snakeCase: UnwrapRef<typeof import('lodash-es')['snakeCase']>
readonly sortBy: UnwrapRef<typeof import('lodash-es')['sortBy']>
readonly startsWith: UnwrapRef<typeof import('lodash-es')['startsWith']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly supportsBackdropFilter: UnwrapRef<typeof import('./src/utils/performance.js')['supportsBackdropFilter']>
readonly supportsHardwareAcceleration: UnwrapRef<typeof import('./src/utils/performance.js')['supportsHardwareAcceleration']>
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
readonly throttle: UnwrapRef<typeof import('./src/utils/performance.js')['throttle']>
readonly throttledRef: UnwrapRef<typeof import('@vueuse/core')['throttledRef']>
readonly throttledWatch: UnwrapRef<typeof import('@vueuse/core')['throttledWatch']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toReactive: UnwrapRef<typeof import('@vueuse/core')['toReactive']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly trim: UnwrapRef<typeof import('lodash-es')['trim']>
readonly tryOnBeforeMount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeMount']>
readonly tryOnBeforeUnmount: UnwrapRef<typeof import('@vueuse/core')['tryOnBeforeUnmount']>
readonly tryOnMounted: UnwrapRef<typeof import('@vueuse/core')['tryOnMounted']>
readonly tryOnScopeDispose: UnwrapRef<typeof import('@vueuse/core')['tryOnScopeDispose']>
readonly tryOnUnmounted: UnwrapRef<typeof import('@vueuse/core')['tryOnUnmounted']>
readonly unescape: UnwrapRef<typeof import('lodash-es')['unescape']>
readonly uniq: UnwrapRef<typeof import('lodash-es')['uniq']>
readonly uniqBy: UnwrapRef<typeof import('lodash-es')['uniqBy']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly upperCase: UnwrapRef<typeof import('lodash-es')['upperCase']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useAppStore: UnwrapRef<typeof import('./src/stores/app.js')['useAppStore']>
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
readonly useArrayEvery: UnwrapRef<typeof import('@vueuse/core')['useArrayEvery']>
readonly useArrayFilter: UnwrapRef<typeof import('@vueuse/core')['useArrayFilter']>
readonly useArrayFind: UnwrapRef<typeof import('@vueuse/core')['useArrayFind']>
readonly useArrayFindIndex: UnwrapRef<typeof import('@vueuse/core')['useArrayFindIndex']>
readonly useArrayFindLast: UnwrapRef<typeof import('@vueuse/core')['useArrayFindLast']>
readonly useArrayIncludes: UnwrapRef<typeof import('@vueuse/core')['useArrayIncludes']>
readonly useArrayJoin: UnwrapRef<typeof import('@vueuse/core')['useArrayJoin']>
readonly useArrayMap: UnwrapRef<typeof import('@vueuse/core')['useArrayMap']>
readonly useArrayReduce: UnwrapRef<typeof import('@vueuse/core')['useArrayReduce']>
readonly useArraySome: UnwrapRef<typeof import('@vueuse/core')['useArraySome']>
readonly useArrayUnique: UnwrapRef<typeof import('@vueuse/core')['useArrayUnique']>
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
readonly useBreakpoints: UnwrapRef<typeof import('@vueuse/core')['useBreakpoints']>
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
readonly useCertification: UnwrapRef<typeof import('./src/composables/useCertification.js')['useCertification']>
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
readonly useConfirmDialog: UnwrapRef<typeof import('@vueuse/core')['useConfirmDialog']>
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
readonly useCounterStore: UnwrapRef<typeof import('./src/stores/counter.js')['useCounterStore']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVar: UnwrapRef<typeof import('@vueuse/core')['useCssVar']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useCurrentElement: UnwrapRef<typeof import('@vueuse/core')['useCurrentElement']>
readonly useCycleList: UnwrapRef<typeof import('@vueuse/core')['useCycleList']>
readonly useDark: UnwrapRef<typeof import('@vueuse/core')['useDark']>
readonly useDateFormat: UnwrapRef<typeof import('@vueuse/core')['useDateFormat']>
readonly useDebounce: UnwrapRef<typeof import('@vueuse/core')['useDebounce']>
readonly useDebounceFn: UnwrapRef<typeof import('@vueuse/core')['useDebounceFn']>
readonly useDebouncedRefHistory: UnwrapRef<typeof import('@vueuse/core')['useDebouncedRefHistory']>
readonly useDeviceMotion: UnwrapRef<typeof import('@vueuse/core')['useDeviceMotion']>
readonly useDeviceOrientation: UnwrapRef<typeof import('@vueuse/core')['useDeviceOrientation']>
readonly useDevicePixelRatio: UnwrapRef<typeof import('@vueuse/core')['useDevicePixelRatio']>
readonly useDevicesList: UnwrapRef<typeof import('@vueuse/core')['useDevicesList']>
readonly useDisplayMedia: UnwrapRef<typeof import('@vueuse/core')['useDisplayMedia']>
readonly useDocumentVisibility: UnwrapRef<typeof import('@vueuse/core')['useDocumentVisibility']>
readonly useDraggable: UnwrapRef<typeof import('@vueuse/core')['useDraggable']>
readonly useDropZone: UnwrapRef<typeof import('@vueuse/core')['useDropZone']>
readonly useElementBounding: UnwrapRef<typeof import('@vueuse/core')['useElementBounding']>
readonly useElementByPoint: UnwrapRef<typeof import('@vueuse/core')['useElementByPoint']>
readonly useElementHover: UnwrapRef<typeof import('@vueuse/core')['useElementHover']>
readonly useElementSize: UnwrapRef<typeof import('@vueuse/core')['useElementSize']>
readonly useElementVisibility: UnwrapRef<typeof import('@vueuse/core')['useElementVisibility']>
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
readonly useFps: UnwrapRef<typeof import('@vueuse/core')['useFps']>
readonly useFullscreen: UnwrapRef<typeof import('@vueuse/core')['useFullscreen']>
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useId: UnwrapRef<typeof import('vue')['useId']>
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
readonly useInfiniteScroll: UnwrapRef<typeof import('@vueuse/core')['useInfiniteScroll']>
readonly useIntersectionObserver: UnwrapRef<typeof import('@vueuse/core')['useIntersectionObserver']>
readonly useInterval: UnwrapRef<typeof import('@vueuse/core')['useInterval']>
readonly useIntervalFn: UnwrapRef<typeof import('@vueuse/core')['useIntervalFn']>
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>
readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useUserStore: UnwrapRef<typeof import('./src/stores/user.js')['useUserStore']>
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
readonly useVModels: UnwrapRef<typeof import('@vueuse/core')['useVModels']>
readonly useVibrate: UnwrapRef<typeof import('@vueuse/core')['useVibrate']>
readonly useVirtualList: UnwrapRef<typeof import('@vueuse/core')['useVirtualList']>
readonly useWakeLock: UnwrapRef<typeof import('@vueuse/core')['useWakeLock']>
readonly useWebNotification: UnwrapRef<typeof import('@vueuse/core')['useWebNotification']>
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
readonly validateEmail: UnwrapRef<typeof import('./src/utils/index.js')['validateEmail']>
readonly validateIdCard: UnwrapRef<typeof import('./src/utils/index.js')['validateIdCard']>
readonly validatePhone: UnwrapRef<typeof import('./src/utils/index.js')['validatePhone']>
readonly version: UnwrapRef<typeof import('./src/utils/version.js')['default']>
readonly versionChecker: UnwrapRef<typeof import('./src/utils/version.js')['versionChecker']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchArray: UnwrapRef<typeof import('@vueuse/core')['watchArray']>
readonly watchAtMost: UnwrapRef<typeof import('@vueuse/core')['watchAtMost']>
readonly watchDebounced: UnwrapRef<typeof import('@vueuse/core')['watchDebounced']>
readonly watchDeep: UnwrapRef<typeof import('@vueuse/core')['watchDeep']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchIgnorable: UnwrapRef<typeof import('@vueuse/core')['watchIgnorable']>
readonly watchImmediate: UnwrapRef<typeof import('@vueuse/core')['watchImmediate']>
readonly watchOnce: UnwrapRef<typeof import('@vueuse/core')['watchOnce']>
readonly watchPausable: UnwrapRef<typeof import('@vueuse/core')['watchPausable']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
readonly watchThrottled: UnwrapRef<typeof import('@vueuse/core')['watchThrottled']>
readonly watchTriggerable: UnwrapRef<typeof import('@vueuse/core')['watchTriggerable']>
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
}
}

94
components.d.ts vendored Normal file
View File

@@ -0,0 +1,94 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AppBreadcrumb: typeof import('./src/components/layout/AppBreadcrumb.vue')['default']
AppHeader: typeof import('./src/components/layout/AppHeader.vue')['default']
AppLoading: typeof import('./src/components/common/AppLoading.vue')['default']
AppSidebar: typeof import('./src/components/layout/AppSidebar.vue')['default']
BusinessConsultationDialog: typeof import('./src/components/common/BusinessConsultationDialog.vue')['default']
CertificationBanner: typeof import('./src/components/common/CertificationBanner.vue')['default']
CertificationNotice: typeof import('./src/components/common/CertificationNotice.vue')['default']
ChartCard: typeof import('./src/components/statistics/ChartCard.vue')['default']
CodeDisplay: typeof import('./src/components/common/CodeDisplay.vue')['default']
CustomSteps: typeof import('./src/components/common/CustomSteps.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
ElContainer: typeof import('element-plus/es')['ElContainer']
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']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
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']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLoading: typeof import('element-plus/es')['ElLoading']
ElMain: typeof import('element-plus/es')['ElMain']
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']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
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']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
ExportDialog: typeof import('./src/components/common/ExportDialog.vue')['default']
FileUpload: typeof import('./src/components/common/FileUpload.vue')['default']
FilterItem: typeof import('./src/components/common/FilterItem.vue')['default']
FilterSection: typeof import('./src/components/common/FilterSection.vue')['default']
FloatingCustomerService: typeof import('./src/components/common/FloatingCustomerService.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
ListPageLayout: typeof import('./src/components/common/ListPageLayout.vue')['default']
MarkdownEditor: typeof import('./src/components/common/MarkdownEditor.vue')['default']
NotificationPanel: typeof import('./src/components/layout/NotificationPanel.vue')['default']
PermissionGuard: typeof import('./src/components/auth/PermissionGuard.vue')['default']
ProductApiConfigDialog: typeof import('./src/components/admin/ProductApiConfigDialog.vue')['default']
ProductCard: typeof import('./src/components/product/ProductCard.vue')['default']
ProductDocumentationDialog: typeof import('./src/components/admin/ProductDocumentationDialog.vue')['default']
ProductFormDialog: typeof import('./src/components/admin/ProductFormDialog.vue')['default']
RichTextEditor: typeof import('./src/components/common/RichTextEditor.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StatCard: typeof import('./src/components/statistics/StatCard.vue')['default']
StatisticsDashboard: typeof import('./src/components/statistics/StatisticsDashboard.vue')['default']
TheWelcome: typeof import('./src/components/TheWelcome.vue')['default']
VersionInfo: typeof import('./src/components/common/VersionInfo.vue')['default']
WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -0,0 +1,202 @@
# API在线调试功能说明
## 功能概述
API在线调试功能为用户提供了一个可视化的界面来测试已订阅的API接口让用户能够
1. **选择产品**从已订阅的产品列表中选择要调试的API
2. **输入参数**:根据产品要求输入测试参数
3. **查看加密过程**:了解参数如何被加密处理
4. **获取响应**查看API的实际响应结果
5. **分析结果**了解API调用的完整过程
## 功能特点
### 1. 产品选择
- 自动加载用户已订阅的产品列表
- 显示产品名称、代码和价格信息
- 只显示有效的订阅产品
### 2. 参数输入
- 统一的参数结构:姓名、身份证号、手机号
- 实时参数验证
- 参数示例和说明
### 3. 加密处理
- 自动使用用户的Secret Key进行AES加密
- 显示原始参数和加密后的参数
- 确保参数安全性
### 4. 调试结果
- 完整的请求信息产品名称、API代码、交易ID等
- 请求时间和响应时间统计
- 成功/失败状态显示
- 原始响应数据展示
## 技术实现
### 后端接口
#### 1. 加密接口
```
POST /api/v1/encrypt
Content-Type: application/json
Authorization: Bearer {token}
{
"data": {
"name": "张三",
"id_card": "110101199001011234",
"mobile": "13800138000"
}
}
```
**响应**
```json
{
"success": true,
"data": {
"encrypted_data": "加密后的Base64字符串"
},
"message": "加密成功"
}
```
#### 2. API调用接口
```
POST /api/v1/{api_code}
Content-Type: application/json
Access-Id: {access_id}
{
"data": "加密后的参数"
}
```
### 前端实现
#### 1. 页面结构
- **调试配置区域**:产品选择、参数输入
- **调试结果区域**:请求信息、响应数据
- **使用说明区域**:操作指南和注意事项
#### 2. 核心功能
- 自动加载用户订阅产品
- 参数验证和格式化
- 加密参数调用
- 结果展示和分析
## 使用流程
### 1. 访问调试页面
- 登录系统后,进入"开发者中心" → "在线调试"
- 页面会自动加载用户的API密钥和订阅产品
### 2. 选择产品
- 从下拉列表中选择要调试的产品
- 系统会显示产品的详细信息
### 3. 输入参数
- 填写姓名(必填)
- 填写身份证号必填18位
- 填写手机号必填11位
### 4. 开始调试
- 点击"开始调试"按钮
- 系统会自动加密参数并调用API
- 等待响应结果
### 5. 查看结果
- 查看请求信息交易ID、响应时间等
- 查看原始参数和加密参数
- 查看API响应结果
## 注意事项
### 1. 费用说明
- 每次调试都会消耗API调用次数
- 会按照产品价格扣除相应费用
- 调试结果会记录在调用记录中
### 2. 数据安全
- 所有参数都经过AES加密处理
- 使用用户的专属Secret Key
- 不会在日志中记录敏感信息
### 3. 使用建议
- 使用真实的测试数据进行调试
- 注意参数格式的正确性
- 调试结果仅供参考
### 4. 错误处理
- 网络错误会显示相应提示
- API调用失败会显示错误信息
- 参数错误会给出具体提示
## 技术架构
### 前端架构
```
ApiDebugger.vue
├── 产品选择组件
├── 参数输入组件
├── 加密处理逻辑
├── 结果展示组件
└── 使用说明组件
```
### 后端架构
```
API Handler
├── 加密接口 (EncryptParams)
├── API调用接口 (HandleApiCall)
└── 密钥管理接口 (GetUserApiKeys)
```
### 数据流
1. 前端获取用户订阅产品
2. 用户输入参数
3. 前端调用加密接口
4. 前端使用加密参数调用API
5. 后端处理并返回结果
6. 前端展示调试结果
## 扩展功能
### 1. 参数模板
- 支持保存常用的参数组合
- 快速填充测试数据
- 参数历史记录
### 2. 批量调试
- 支持多个API同时调试
- 批量参数输入
- 结果对比分析
### 3. 调试历史
- 保存调试记录
- 结果回放功能
- 性能统计分析
### 4. 高级功能
- 自定义参数验证
- 响应数据解析
- 错误模式分析
## 常见问题
### Q1: 为什么看不到某些产品?
A: 只有已订阅且有效的产品才会显示在列表中。
### Q2: 调试失败怎么办?
A: 检查参数格式是否正确,网络连接是否正常,账户余额是否充足。
### Q3: 如何查看详细的错误信息?
A: 在调试结果区域会显示完整的错误信息和响应数据。
### Q4: 调试会消耗费用吗?
A: 是的,每次调试都会按照产品价格扣除相应费用。
### Q5: 如何保护我的API密钥
A: Secret Key默认隐藏显示可以点击"显示"按钮查看,建议不要泄露给他人。

View File

@@ -0,0 +1,512 @@
# API调用记录和钱包交易记录前端功能说明
## 概述
本文档描述了新增的API调用记录和钱包交易记录前端页面功能包括页面设计、交互逻辑和技术实现。
## 页面功能
### 1. API调用记录页面 (`/api/usage`)
#### 页面布局
- **标题**: "调用记录"
- **副标题**: "查看您的API调用历史记录"
- **布局组件**: `ListPageLayout`
#### 功能模块
##### 1.1 统计信息区域
- **总调用次数**: 显示用户的总API调用次数
- **成功率**: 显示API调用的成功率百分比
- **样式**: 卡片式设计,带有渐变背景和阴影效果
##### 1.2 筛选功能
- **搜索调用**: 支持按交易ID或产品名称搜索
- **调用状态**: 下拉选择(全部/成功/失败/处理中)
- **时间范围**: 日期时间范围选择器
- **操作按钮**: 重置筛选、应用筛选
##### 1.3 数据表格
**列定义**:
- **交易ID**: 显示API调用的唯一标识
- **接口名称**: 显示调用的API接口名称和ID
- **状态**: 状态标签(成功/失败/处理中)
- **费用**: 显示调用产生的费用
- **客户端IP**: 显示调用来源IP
- **调用时间**: 显示API调用的开始时间
- **完成时间**: 显示API调用的完成时间
- **操作**: 查看详情按钮
##### 1.4 详情弹窗
**显示内容**:
- **基本信息**: 交易ID、状态、接口名称、费用、客户端IP
- **时间信息**: 调用时间、完成时间
- **错误信息**: 错误类型和错误消息(如果有)
- **请求参数**: JSON格式的请求参数
- **响应数据**: JSON格式的响应数据
##### 1.5 分页功能
- **分页器**: 支持页码跳转、每页数量选择
- **统计信息**: 显示总记录数和当前页信息
#### 交互逻辑
##### 搜索功能
```javascript
// 防抖搜索,避免频繁请求
const handleSearch = () => {
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
currentPage.value = 1
loadApiCalls()
}, 500)
}
```
##### 筛选功能
```javascript
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadApiCalls()
}
// 处理时间范围变化
const handleDateRangeChange = (range) => {
if (range && range.length === 2) {
filters.start_time = range[0]
filters.end_time = range[1]
} else {
filters.start_time = ''
filters.end_time = ''
}
currentPage.value = 1
loadApiCalls()
}
```
##### 数据加载
```javascript
// 加载API调用记录
const loadApiCalls = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters
}
const response = await apiApi.getUserApiCalls(params)
apiCalls.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载API调用记录失败:', error)
ElMessage.error('加载API调用记录失败')
} finally {
loading.value = false
}
}
```
### 2. 钱包交易记录页面 (`/finance/transactions`)
#### 页面布局
- **标题**: "消费记录"
- **副标题**: "查看您的钱包消费历史记录"
- **布局组件**: `ListPageLayout`
#### 功能模块
##### 2.1 统计信息区域
- **总消费次数**: 显示用户的总消费次数
- **总消费金额**: 显示用户的总消费金额
- **样式**: 卡片式设计,金额显示为红色突出
##### 2.2 筛选功能
- **搜索交易**: 支持按API调用ID搜索
- **金额范围**: 最小金额和最大金额输入框
- **时间范围**: 日期时间范围选择器
- **操作按钮**: 重置筛选、应用筛选
##### 2.3 数据表格
**列定义**:
- **交易ID**: 显示钱包交易的唯一标识
- **API调用ID**: 显示关联的API调用ID
- **消费金额**: 显示消费金额(红色突出)
- **消费时间**: 显示交易创建时间
- **更新时间**: 显示交易最后更新时间
- **操作**: 查看详情按钮
##### 2.4 详情弹窗
**显示内容**:
- **基本信息**: 交易ID、用户ID、API调用ID、消费金额
- **时间信息**: 创建时间、更新时间
- **交易说明**: 解释交易记录的用途和含义
##### 2.5 分页功能
- **分页器**: 支持页码跳转、每页数量选择
- **统计信息**: 显示总记录数和当前页信息
#### 交互逻辑
##### 数据加载
```javascript
// 加载钱包交易记录
const loadTransactions = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters
}
const response = await financeApi.getUserWalletTransactions(params)
transactions.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载钱包交易记录失败:', error)
ElMessage.error('加载钱包交易记录失败')
} finally {
loading.value = false
}
}
```
##### 统计计算
```javascript
// 加载统计数据
const loadStats = async () => {
try {
// 计算统计数据
const totalAmount = transactions.value.reduce((sum, transaction) => {
return sum + Number(transaction.amount || 0)
}, 0)
stats.value = {
total_transactions: total.value,
total_amount: totalAmount
}
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
```
## 技术实现
### 1. 组件架构
#### 通用组件
- **ListPageLayout**: 列表页面布局组件
- **FilterSection**: 筛选区域组件
- **FilterItem**: 筛选项组件
- **LoadingSpinner**: 加载动画组件
#### 页面组件
- **Usage.vue**: API调用记录页面
- **Transactions.vue**: 钱包交易记录页面
### 2. API接口
#### API调用记录接口
```javascript
// API相关接口
export const apiApi = {
// 用户API调用记录
getUserApiCalls: (params) => request.get('/api/v1/my/api-calls', { params })
}
```
#### 钱包交易记录接口
```javascript
// 财务相关接口
export const financeApi = {
// 钱包交易记录
getUserWalletTransactions: (params) => request.get('/finance/wallet/transactions', { params })
}
```
### 3. 状态管理
#### 响应式数据
```javascript
// 页面数据
const loading = ref(false)
const apiCalls = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
// 筛选条件
const filters = reactive({
keyword: '',
status: '',
start_time: '',
end_time: ''
})
// 统计数据
const stats = ref({
total_calls: 0,
success_rate: '0%'
})
```
### 4. 工具函数
#### 格式化函数
```javascript
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 格式化JSON
const formatJson = (jsonString) => {
try {
return JSON.stringify(JSON.parse(jsonString), null, 2)
} catch {
return jsonString
}
}
```
#### 状态处理函数
```javascript
// 获取状态类型
const getStatusType = (status) => {
switch (status) {
case 'success':
return 'success'
case 'failed':
return 'danger'
case 'pending':
return 'warning'
default:
return 'info'
}
}
// 获取状态文本
const getStatusText = (status) => {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'pending':
return '处理中'
default:
return '未知'
}
}
```
## 样式设计
### 1. 统计卡片样式
```css
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 24px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border: 1px solid rgba(226, 232, 240, 0.6);
border-radius: 12px;
min-width: 120px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #1e293b;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #64748b;
font-weight: 500;
}
```
### 2. 详情弹窗样式
```css
.detail-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.detail-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.detail-dialog :deep(.el-dialog__body) {
padding: 24px;
}
```
### 3. 表格样式优化
```css
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
```
### 4. 响应式设计
```css
/* 响应式设计 */
@media (max-width: 768px) {
.stat-item {
padding: 12px 16px;
min-width: 100px;
}
.stat-value {
font-size: 20px;
}
.stat-label {
font-size: 12px;
}
.detail-item {
margin-bottom: 16px;
}
.detail-label {
font-size: 13px;
}
.detail-value {
font-size: 14px;
}
}
```
## 用户体验优化
### 1. 加载状态
- **加载动画**: 使用Element Plus的LoadingSpinner组件
- **骨架屏**: 数据加载时显示占位内容
- **错误处理**: 友好的错误提示信息
### 2. 交互反馈
- **防抖搜索**: 避免频繁的API请求
- **即时反馈**: 操作后立即更新界面状态
- **确认操作**: 重要操作需要用户确认
### 3. 数据展示
- **分页加载**: 避免一次性加载大量数据
- **虚拟滚动**: 大数据量时的性能优化
- **数据缓存**: 减少重复请求
### 4. 移动端适配
- **响应式布局**: 适配不同屏幕尺寸
- **触摸友好**: 按钮和交互元素适合触摸操作
- **性能优化**: 移动端性能优化
## 错误处理
### 1. 网络错误
```javascript
try {
const response = await apiApi.getUserApiCalls(params)
// 处理成功响应
} catch (error) {
console.error('加载API调用记录失败:', error)
ElMessage.error('加载API调用记录失败')
}
```
### 2. 数据验证
```javascript
// 验证时间格式
const handleDateRangeChange = (range) => {
if (range && range.length === 2) {
// 验证时间格式
const startTime = new Date(range[0])
const endTime = new Date(range[1])
if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) {
ElMessage.warning('时间格式不正确')
return
}
filters.start_time = range[0]
filters.end_time = range[1]
}
}
```
### 3. 边界情况
```javascript
// 处理空数据
<div v-else-if="apiCalls.length === 0" class="text-center py-12">
<el-empty description="暂无调用记录">
<el-button type="primary" @click="$router.push('/api')">
前往开发者中心
</el-button>
</el-empty>
</div>
```
## 性能优化
### 1. 组件优化
- **懒加载**: 路由级别的组件懒加载
- **缓存**: 使用keep-alive缓存页面状态
- **虚拟化**: 大数据列表的虚拟滚动
### 2. 请求优化
- **防抖**: 搜索输入防抖处理
- **缓存**: API响应数据缓存
- **预加载**: 关键数据的预加载
### 3. 渲染优化
- **v-show vs v-if**: 合理使用条件渲染
- **key属性**: 列表渲染时使用唯一key
- **计算属性**: 复杂计算的缓存
## 总结
API调用记录和钱包交易记录前端页面提供了完整的用户界面和交互体验通过合理的组件设计、状态管理和样式优化确保了良好的用户体验和系统性能。页面支持响应式设计适配不同设备并提供了丰富的筛选和查看功能。

View File

@@ -0,0 +1,164 @@
# 产品API配置管理功能说明
## 功能概述
产品API配置管理功能允许管理员为每个产品配置API接口的请求参数、响应字段和响应示例这些配置将用于前端的在线调试功能。
## 功能特性
### 1. 请求参数配置
- **参数名称**: 显示给用户的参数名称(如:姓名)
- **字段名**: API接口中使用的字段名name
- **参数类型**: 支持文本、数字、密码、邮箱、手机号、身份证等类型
- **是否必填**: 标识该参数是否为必填项
- **参数示例**: 提供给用户的输入示例
- **验证规则**: 正则表达式验证规则
- **参数描述**: 参数的详细说明
### 2. 响应字段配置
- **字段名称**: 显示给用户的字段名称(如:姓名)
- **字段路径**: JSON响应中的字段路径data.name
- **字段类型**: 支持字符串、数字、布尔值、对象、数组等类型
- **是否必填**: 标识该字段是否在响应中必填
- **字段示例**: 字段的示例值
- **字段描述**: 字段的详细说明
### 3. 响应示例
- **JSON格式**: 完整的API响应示例
- **实时验证**: 确保输入的JSON格式正确
- **格式化显示**: 自动格式化JSON内容
## 使用方法
### 1. 访问产品管理页面
1. 登录管理员账户
2. 进入"产品管理"页面
3. 在产品列表中找到需要配置的产品
### 2. 配置API
1. 点击产品行的"配置API"按钮
2. 在弹出的配置窗口中:
- 查看产品基本信息
- 添加/编辑请求参数
- 添加/编辑响应字段
- 输入JSON响应示例
3. 点击"创建"或"更新"保存配置
### 3. 配置示例
#### 请求参数配置示例
```json
[
{
"name": "姓名",
"field": "name",
"type": "text",
"required": true,
"description": "用户真实姓名",
"example": "张三",
"validation": "^[\\u4e00-\\u9fa5]{2,4}$"
},
{
"name": "身份证号",
"field": "id_card",
"type": "idcard",
"required": true,
"description": "用户身份证号码",
"example": "110101199001011234",
"validation": "^[1-9]\\d{5}(18|19|20)\\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\\d{3}[0-9Xx]$"
}
]
```
#### 响应字段配置示例
```json
[
{
"name": "结果",
"path": "result",
"type": "string",
"required": true,
"description": "查询结果",
"example": "success"
},
{
"name": "数据",
"path": "data",
"type": "object",
"required": false,
"description": "查询结果数据",
"example": "{}"
}
]
```
#### 响应示例
```json
{
"result": "success",
"code": 200,
"message": "查询成功",
"data": {
"name": "张三",
"id_card": "110101199001011234",
"mobile": "13800138000",
"status": "正常",
"query_time": "2024-01-01 12:00:00"
}
}
```
## 技术实现
### 后端架构
- **实体层**: `ProductApiConfig` 实体
- **仓库层**: `ProductApiConfigRepository` 接口和实现
- **领域服务层**: `ProductApiConfigService` 业务逻辑
- **应用服务层**: `ProductApiConfigApplicationService` 业务流程
- **HTTP层**: `ProductAdminHandler` 接口处理
### 前端架构
- **组件**: `ProductApiConfigDialog.vue` 配置弹窗
- **页面**: 集成到产品管理页面
- **API**: 通过 `productAdminApi` 调用后端接口
### 数据库设计
```sql
CREATE TABLE product_api_configs (
id VARCHAR(36) PRIMARY KEY,
product_id VARCHAR(36) NOT NULL UNIQUE,
request_params JSON NOT NULL,
response_fields JSON NOT NULL,
response_example JSON NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
FOREIGN KEY (product_id) REFERENCES products(id)
);
```
## 初始化脚本
项目提供了初始化脚本 `scripts/init_product_api_configs.go`可以为现有产品创建默认的API配置
```bash
cd tyapi-server-gin
go run scripts/init_product_api_configs.go
```
## 注意事项
1. **唯一性**: 每个产品只能有一个API配置
2. **JSON格式**: 响应示例必须是有效的JSON格式
3. **字段路径**: 响应字段路径使用点号分隔data.name
4. **验证规则**: 支持正则表达式验证规则
5. **权限控制**: 只有管理员可以配置API
## 扩展功能
未来可以考虑添加的功能:
1. **配置模板**: 提供常用的配置模板
2. **批量导入**: 支持批量导入配置
3. **版本管理**: 支持配置版本管理
4. **测试功能**: 集成API测试功能
5. **文档生成**: 自动生成API文档

View File

@@ -0,0 +1,196 @@
# 充值功能前端说明
## 概述
前端充值功能包含两个主要页面:
1. **钱包充值页面** (`/finance/wallet`) - 用户选择充值方式并进行充值
2. **充值成功页面** (`/finance/wallet/success`) - 支付宝充值成功后的重定向页面
## 页面功能
### 1. 钱包充值页面 (`Wallet.vue`)
#### 功能特性
- **钱包余额展示** - 显示用户当前钱包余额
- **充值方式选择** - 支持支付宝充值和对公转账两种方式
- **支付宝充值** - 输入充值金额,跳转到支付宝支付(待接入)
- **对公转账** - 显示银行账户信息,支持转账记录提交
#### 页面结构
```
钱包充值页面
├── 页面头部
│ ├── 标题:钱包充值
│ └── 返回按钮
├── 钱包余额信息
│ ├── 余额图标
│ └── 当前余额显示
├── 充值方式选择
│ ├── 支付宝充值卡片
│ └── 对公转账卡片
├── 支付宝充值表单(选中时显示)
│ ├── 充值金额输入
│ └── 立即充值按钮
└── 对公转账信息(选中时显示)
├── 银行账户信息
├── 转账说明
└── 转账记录表单
```
#### 交互流程
**支付宝充值流程:**
1. 用户选择"支付宝充值"
2. 输入充值金额最低1元
3. 点击"立即充值"
4. 确认充值金额
5. 跳转到支付宝支付页面(待实现)
6. 支付成功后跳转到充值成功页面
**对公转账流程:**
1. 用户选择"对公转账"
2. 查看银行账户信息
3. 复制银行账号(可选)
4. 进行银行转账
5. 填写转账记录表单
6. 提交转账记录
7. 等待人工确认1-2个工作日
### 2. 充值成功页面 (`WalletSuccess.vue`)
#### 功能特性
- **成功状态展示** - 显示充值成功图标和标题
- **充值详情** - 显示充值金额、方式、时间、当前余额
- **操作按钮** - 提供多个后续操作选项
- **温馨提示** - 显示相关提示信息
#### 页面结构
```
充值成功页面
├── 成功状态
│ ├── 成功图标
│ ├── 成功标题
│ └── 成功副标题
├── 充值详情
│ ├── 充值金额
│ ├── 充值方式
│ ├── 充值时间
│ └── 当前余额
├── 操作按钮
│ ├── 返回财务中心
│ ├── 去订阅产品
│ └── 开发者中心
└── 温馨提示
└── 相关提示信息
```
## API接口
### 财务相关接口
```javascript
// 获取钱包信息
financeApi.getWallet()
// 对公转账充值
financeApi.transferRecharge({
amount: 100.00,
transfer_order_id: 'TR202412010001',
bank_account: '6222021234567890123',
bank_name: '中国工商银行',
notes: '转账备注'
})
// 赠送充值(管理员使用)
financeApi.giftRecharge({
amount: 50.00,
gift_reason: '新用户注册奖励',
notes: '系统自动赠送'
})
```
## 样式设计
### 设计风格
- 遵循项目整体设计风格
- 使用渐变色彩和卡片式布局
- 响应式设计,支持移动端
- 统一的颜色和字体规范
### 主要样式特点
- **渐变背景** - 钱包余额卡片使用紫色渐变
- **卡片布局** - 充值方式选择使用卡片式设计
- **悬停效果** - 充值方式卡片支持悬停动画
- **响应式** - 移动端适配,布局自适应
## 配置信息
### 对公转账信息配置
```javascript
const transferInfo = {
bankName: '中国工商银行',
bankAccount: '6222021234567890123',
accountName: '某某科技有限公司'
}
```
### 用户信息配置
```javascript
const userInfo = {
userId: 'USER123456' // 实际应从用户状态获取
}
```
## 路由配置
```javascript
{
path: '/finance',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'wallet',
name: 'Wallet',
component: () => import('@/pages/finance/Wallet.vue'),
meta: { title: '余额充值' }
},
{
path: 'wallet/success',
name: 'WalletSuccess',
component: () => import('@/pages/finance/WalletSuccess.vue'),
meta: { title: '充值成功' }
}
]
}
```
## 后续开发计划
### 1. 支付宝支付集成
- 集成支付宝支付SDK
- 实现支付页面跳转
- 处理支付回调
- 添加支付状态查询
### 2. 充值记录页面
- 创建充值记录列表页面
- 支持按类型、状态筛选
- 显示充值详情和状态
### 3. 实时余额更新
- 实现WebSocket实时余额更新
- 添加余额变动通知
### 4. 移动端优化
- 优化移动端交互体验
- 添加手势操作支持
## 注意事项
1. **金额验证** - 充值金额必须大于等于1元
2. **表单验证** - 所有必填字段都有相应的验证规则
3. **错误处理** - 完善的错误提示和异常处理
4. **用户体验** - 加载状态、成功提示等交互反馈
5. **安全性** - 敏感信息(如银行账号)的显示和复制功能
6. **响应式** - 确保在不同设备上的良好显示效果

View File

@@ -0,0 +1,295 @@
# 支付宝充值功能说明
## 功能概述
本功能实现了完整的支付宝充值流程,从前端用户操作到后端支付处理,再到支付回调确认,形成了完整的闭环。
## 功能特性
### 1. 前端功能
- **钱包余额显示**:实时显示用户当前钱包余额和状态
- **充值方式选择**:支持支付宝充值和对公转账两种方式
- **支付宝充值表单**:用户输入充值金额,系统创建支付订单
- **支付跳转**:自动跳转到支付宝支付页面
- **支付结果页面**
- 支付成功页面:显示充值详情和操作按钮
- 支付失败页面:显示失败原因和重试选项
### 2. 后端功能
- **支付宝订单创建**:生成唯一订单号,创建支付宝支付订单
- **异步回调处理**:处理支付宝支付成功通知,更新钱包余额
- **同步回调处理**:处理用户支付完成后的页面跳转
- **充值记录管理**:完整的充值记录创建、查询和管理
- **事务保护**:确保充值过程的原子性和数据一致性
## 技术架构
### 前端架构
```
Wallet.vue (钱包页面)
├── 余额显示
├── 充值方式选择
├── 支付宝充值表单
└── 支付跳转逻辑
WalletSuccess.vue (支付成功页面)
├── 成功状态显示
├── 充值详情展示
└── 操作按钮
WalletFail.vue (支付失败页面)
├── 失败状态显示
├── 失败原因说明
└── 重试和客服选项
```
### 后端架构
```
FinanceHandler (HTTP处理器)
├── CreateAlipayRecharge (创建充值订单)
├── HandleAlipayCallback (异步回调处理)
└── HandleAlipayReturn (同步回调处理)
FinanceApplicationService (应用服务)
├── CreateAlipayRechargeOrder (业务流程编排)
├── HandleAlipayCallback (回调处理)
└── GetUserRechargeRecords (充值记录查询)
RechargeRecordService (充值记录服务)
├── CreateAlipayRecharge (创建充值记录)
├── CreateAlipayOrder (创建支付宝订单)
└── HandleAlipayPaymentSuccess (支付成功处理)
AliPayService (支付宝服务)
├── CreateAlipayOrder (创建支付订单)
├── HandleAliPaymentNotification (回调验证)
└── GenerateOutTradeNo (生成订单号)
```
## API接口
### 1. 创建支付宝充值订单
```
POST /api/v1/finance/wallet/alipay-recharge
Content-Type: application/json
Authorization: Bearer <token>
{
"amount": 100.00,
"subject": "钱包充值 ¥100.00",
"platform": "pc"
}
Response:
{
"code": 200,
"message": "支付宝充值订单创建成功",
"data": {
"pay_url": "https://openapi.alipay.com/...",
"out_trade_no": "202412011234567890",
"amount": 100.00,
"platform": "pc",
"subject": "钱包充值 ¥100.00"
}
}
```
### 2. 支付宝异步回调
```
POST /api/v1/finance/alipay/callback
Content-Type: application/x-www-form-urlencoded
支付宝回调参数...
Response: "success"
```
### 3. 支付宝同步回调
```
GET /api/v1/finance/alipay/return?out_trade_no=xxx&trade_no=xxx&trade_status=TRADE_SUCCESS&total_amount=100.00
Response: 302 Redirect to frontend success/fail page
```
### 4. 获取用户充值记录
```
GET /api/v1/finance/wallet/recharge-records?page=1&page_size=10&recharge_type=alipay&status=success
Authorization: Bearer <token>
Response:
{
"code": 200,
"message": "获取充值记录成功",
"data": {
"items": [...],
"total": 50,
"page": 1,
"size": 10
}
}
```
## 支付流程
### 1. 用户充值流程
1. 用户进入钱包页面,查看当前余额
2. 选择"支付宝充值"方式
3. 输入充值金额最低1元
4. 点击"立即充值"按钮
5. 前端调用后端创建充值订单接口
6. 后端生成订单号,创建支付宝支付订单
7. 返回支付链接给前端
8. 前端自动跳转到支付宝支付页面
9. 用户在支付宝完成支付
10. 支付宝跳转回系统同步回调地址
11. 后端处理同步回调,跳转到前端成功/失败页面
12. 支付宝异步通知后端支付结果
13. 后端处理异步回调,更新钱包余额和充值记录状态
### 2. 支付回调处理
- **异步回调**:支付宝主动通知支付结果,用于更新订单状态和钱包余额
- **同步回调**:用户支付完成后页面跳转,用于用户体验优化
## 数据库设计
### 1. 充值记录表 (recharge_records)
```sql
CREATE TABLE recharge_records (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
amount DECIMAL(20,8) NOT NULL,
recharge_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
alipay_order_id VARCHAR(64) UNIQUE,
transfer_order_id VARCHAR(64) UNIQUE,
notes VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL
);
```
### 2. 支付宝订单表 (alipay_orders)
```sql
CREATE TABLE alipay_orders (
id VARCHAR(36) PRIMARY KEY,
recharge_id VARCHAR(36) NOT NULL UNIQUE,
out_trade_no VARCHAR(64) NOT NULL UNIQUE,
trade_no VARCHAR(64) UNIQUE,
subject VARCHAR(200) NOT NULL,
amount DECIMAL(20,8) NOT NULL,
platform VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
buyer_id VARCHAR(64),
seller_id VARCHAR(64),
pay_amount DECIMAL(20,8),
receipt_amount DECIMAL(20,8),
notify_time TIMESTAMP NULL,
return_time TIMESTAMP NULL,
error_code VARCHAR(64),
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
## 配置说明
### 1. 支付宝配置
```yaml
alipay:
app_id: "2021004181633376"
private_key: "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSj..."
alipay_public_key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."
is_production: true
notify_url: "https://console.tianyuanapi.com/api/v1/finance/alipay/callback"
return_url: "https://console.tianyuanapi.com/api/v1/finance/alipay/return"
```
### 2. 环境配置
- **开发环境**:使用沙箱环境,回调地址指向开发服务器
- **生产环境**:使用正式环境,回调地址指向生产服务器
## 安全考虑
### 1. 支付安全
- 使用支付宝官方SDK进行签名验证
- 异步回调验证签名确保数据真实性
- 订单号唯一性检查防止重复处理
- 金额验证确保支付金额正确
### 2. 数据安全
- 所有敏感操作使用事务保护
- 充值记录状态变更的原子性
- 钱包余额更新的并发控制
- 完整的操作日志记录
## 错误处理
### 1. 支付失败处理
- 网络异常:提示用户重试
- 余额不足:提示用户检查支付账户
- 订单超时:自动取消订单,允许重新创建
- 系统异常:记录错误日志,提供客服支持
### 2. 回调异常处理
- 签名验证失败:记录异常,不处理回调
- 重复回调:幂等性处理,避免重复更新
- 数据不一致:记录异常,人工介入处理
## 监控和日志
### 1. 关键日志点
- 充值订单创建
- 支付宝回调接收
- 支付成功处理
- 钱包余额更新
- 异常错误记录
### 2. 监控指标
- 充值成功率
- 支付响应时间
- 回调处理延迟
- 异常错误率
## 测试建议
### 1. 功能测试
- 正常充值流程测试
- 支付失败场景测试
- 网络异常处理测试
- 并发充值测试
### 2. 安全测试
- 回调签名验证测试
- 重复订单处理测试
- 金额篡改防护测试
- 并发安全测试
## 部署注意事项
### 1. 环境配置
- 确保支付宝配置正确
- 回调地址可访问性
- 数据库连接稳定性
- 日志存储空间充足
### 2. 监控告警
- 支付成功率监控
- 系统异常告警
- 数据库性能监控
- 网络连接监控
## 后续优化
### 1. 功能增强
- 支持更多支付方式(微信支付、银行卡等)
- 充值优惠活动
- 自动充值功能
- 充值提醒功能
### 2. 性能优化
- 支付订单缓存
- 异步处理优化
- 数据库查询优化
- 前端体验优化

121
eslint.config.js Normal file
View File

@@ -0,0 +1,121 @@
import js from '@eslint/js'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import pluginOxlint from 'eslint-plugin-oxlint'
import pluginVue from 'eslint-plugin-vue'
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
// 自动引入的全局变量
'ref': 'readonly',
'reactive': 'readonly',
'computed': 'readonly',
'watch': 'readonly',
'watchEffect': 'readonly',
'onMounted': 'readonly',
'onUnmounted': 'readonly',
'onBeforeMount': 'readonly',
'onBeforeUnmount': 'readonly',
'nextTick': 'readonly',
'defineComponent': 'readonly',
'h': 'readonly',
'inject': 'readonly',
'provide': 'readonly',
'toRef': 'readonly',
'toRefs': 'readonly',
'unref': 'readonly',
'isRef': 'readonly',
'isReactive': 'readonly',
'markRaw': 'readonly',
'shallowRef': 'readonly',
'shallowReactive': 'readonly',
'readonly': 'readonly',
'shallowReadonly': 'readonly',
'toRaw': 'readonly',
'customRef': 'readonly',
'triggerRef': 'readonly',
'getCurrentInstance': 'readonly',
'useAttrs': 'readonly',
'useSlots': 'readonly',
'useCssModule': 'readonly',
'useCssVars': 'readonly',
'useId': 'readonly',
'useModel': 'readonly',
'useTemplateRef': 'readonly',
'useLink': 'readonly',
'useRoute': 'readonly',
'useRouter': 'readonly',
'onBeforeRouteLeave': 'readonly',
'onBeforeRouteUpdate': 'readonly',
'defineStore': 'readonly',
'storeToRefs': 'readonly',
'createPinia': 'readonly',
'setActivePinia': 'readonly',
'getActivePinia': 'readonly',
'mapState': 'readonly',
'mapGetters': 'readonly',
'mapActions': 'readonly',
'mapStores': 'readonly',
'mapWritableState': 'readonly',
'ElMessage': 'readonly',
'ElMessageBox': 'readonly',
'ElNotification': 'readonly',
'ElLoading': 'readonly',
'axios': 'readonly',
'dayjs': 'readonly',
'isEmpty': 'readonly',
'isEqual': 'readonly',
'get': 'readonly',
'set': 'readonly',
'omit': 'readonly',
'pick': 'readonly',
'merge': 'readonly',
'uniq': 'readonly',
'uniqBy': 'readonly',
'groupBy': 'readonly',
'keyBy': 'readonly',
'sortBy': 'readonly',
'orderBy': 'readonly',
'filter': 'readonly',
'map': 'readonly',
'reduce': 'readonly',
'find': 'readonly',
'findIndex': 'readonly',
'includes': 'readonly',
'startsWith': 'readonly',
'endsWith': 'readonly',
'camelCase': 'readonly',
'kebabCase': 'readonly',
'snakeCase': 'readonly',
'capitalize': 'readonly',
'upperCase': 'readonly',
'lowerCase': 'readonly',
'trim': 'readonly',
'escape': 'readonly',
'unescape': 'readonly',
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
...pluginOxlint.configs['flat/recommended'],
{
rules: {
'vue/multi-word-component-names': 'off',
'no-unused-vars': 'warn',
},
},
skipFormatting,
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<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.js"></script>
</body>
</html>

8
jsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

7443
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

60
package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"name": "tyapi-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix",
"lint": "run-s lint:*",
"format": "prettier --write src/"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.11",
"@tinymce/tinymce-vue": "^6.3.0",
"@vueuse/core": "^13.5.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.11.0",
"dayjs": "^1.11.13",
"echarts": "^6.0.0",
"element-plus": "^2.10.4",
"lodash-es": "^4.17.21",
"marked": "^16.1.1",
"pinia": "^3.0.3",
"tinymce": "^8.0.2",
"unplugin-auto-import": "^19.3.0",
"unplugin-vue-components": "^28.8.0",
"vue": "^3.5.18",
"vue-json-editor": "^1.4.3",
"vue-pdf-embed": "^2.1.3",
"vue-router": "^4.5.1",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-oxlint": "~1.8.0",
"eslint-plugin-vue": "~10.3.0",
"globals": "^16.3.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.8.0",
"postcss": "^8.5.6",
"prettier": "3.6.2",
"sass-embedded": "^1.89.2",
"tailwindcss": "^4.1.11",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0"
},
"packageManager": "pnpm@10.13.1"
}

View File

@@ -0,0 +1,282 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Net.Http;
using System.Threading.Tasks;
public class Program
{
// ==================== 配置区域 ====================
// 请根据实际情况修改以下配置参数
// AES加密密钥 (16进制字符串32位)
// API接口配置
private const string InterfaceName = "XXXXXXXX"; // 接口编号
private const string AccessId = "XXXXXXXXXXX"; // 访问ID
private const string EncryptionKey = "XXXXXXXXXXXXXXXXXXX"; // 加密密钥
private const string BaseUrl = "https://api.tianyuanapi.com"; // API基础URL
// 测试数据配置
private const string TestMobileNo = "13700000000"; // 测试手机号
private const string TestIdCard = "XXXXXXXXXXXXX"; // 测试身份证号
private const string TestName = "XXXXXXXX"; // 测试姓名
private const string TestAuthDate = "20250318-20270318"; // 测试授权日期
// HTTP请求配置
private const int RequestTimeout = 30000; // 请求超时时间(毫秒)
// ==================== 主程序 ====================
public static async Task Main()
{
Console.WriteLine("===== AES CBC 加密解密演示 =====");
// 1. 创建一个JSON对象
var data = new
{
mobile_no = TestMobileNo
};
// 将JSON对象序列化为字符串
string jsonString = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine("\n原始JSON:");
Console.WriteLine(jsonString);
// 2. 加密JSON
Console.WriteLine("\n开始加密...");
string encryptedJson = Encrypt(jsonString);
Console.WriteLine($"加密成功! 加密后Base64:");
Console.WriteLine(encryptedJson);
// 3. 解密JSON
Console.WriteLine("\n开始解密...");
string decryptedJson = Decrypt(encryptedJson);
Console.WriteLine($"解密成功! 解密后的原始JSON:");
Console.WriteLine(decryptedJson);
// 4. 验证加解密一致性
Console.WriteLine("\n验证结果:");
if (jsonString == decryptedJson)
{
Console.WriteLine("✅ 加解密前后内容完全一致");
// 验证原始数据是否可用
Console.WriteLine("\n尝试解析解密后的JSON:");
try
{
var deserializedObject = JsonSerializer.Deserialize(decryptedJson, typeof(object));
Console.WriteLine($"✅ JSON解析成功类型: {deserializedObject.GetType()}");
}
catch (Exception ex)
{
Console.WriteLine($"❌ JSON解析失败: {ex.Message}");
}
}
else
{
Console.WriteLine("❌ 加解密前后内容不一致!");
Console.WriteLine("原始长度: " + jsonString.Length);
Console.WriteLine("解密后长度: " + decryptedJson.Length);
// 找出第一个不同的位置
for (int i = 0; i < Math.Min(jsonString.Length, decryptedJson.Length); i++)
{
if (jsonString[i] != decryptedJson[i])
{
Console.WriteLine($"第一个差异在位置 {i}:");
Console.WriteLine($"原始字符: '{jsonString[i]}' ({(int)jsonString[i]})");
Console.WriteLine($"解密字符: '{decryptedJson[i]}' ({(int)decryptedJson[i]})");
break;
}
}
}
// 5. 演示API调用流程可选
Console.WriteLine("\n===== 演示API调用流程 =====");
await DemonstrateApiCall();
}
// ==================== AES加密解密方法 ====================
/// <summary>
/// AES CBC 加密函数
/// 使用PKCS7填充随机IV返回Base64编码的密文
/// </summary>
/// <param name="plainText">要加密的明文</param>
/// <returns>Base64编码的密文</returns>
public static string Encrypt(string plainText)
{
byte[] key = HexToBytes(EncryptionKey);
using Aes aes = Aes.Create();
aes.Key = key;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.GenerateIV();
byte[] iv = aes.IV;
using ICryptoTransform encryptor = aes.CreateEncryptor();
using MemoryStream ms = new();
// 先写入IV
ms.Write(iv, 0, iv.Length);
using (CryptoStream cs = new(ms, encryptor, CryptoStreamMode.Write))
using (StreamWriter sw = new(cs))
{
sw.Write(plainText);
}
return Convert.ToBase64String(ms.ToArray());
}
/// <summary>
/// AES CBC 解密函数
/// 从Base64密文中提取IV和密文数据进行解密
/// </summary>
/// <param name="cipherText">Base64编码的密文</param>
/// <returns>解密后的明文</returns>
public static string Decrypt(string cipherText)
{
byte[] key = HexToBytes(EncryptionKey);
byte[] fullData = Convert.FromBase64String(cipherText);
// 提取前16字节作为IV
byte[] iv = new byte[16];
Buffer.BlockCopy(fullData, 0, iv, 0, iv.Length);
// 实际密文数据
byte[] cipherData = new byte[fullData.Length - 16];
Buffer.BlockCopy(fullData, 16, cipherData, 0, cipherData.Length);
using Aes aes = Aes.Create();
aes.Key = key;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using ICryptoTransform decryptor = aes.CreateDecryptor();
using MemoryStream ms = new(cipherData);
using CryptoStream cs = new(ms, decryptor, CryptoStreamMode.Read);
using StreamReader sr = new(cs);
return sr.ReadToEnd();
}
/// <summary>
/// 将16进制字符串转换为字节数组
/// </summary>
/// <param name="hex">16进制字符串</param>
/// <returns>字节数组</returns>
private static byte[] HexToBytes(string hex)
{
byte[] bytes = new byte[hex.Length / 2];
for (int i = 0; i < hex.Length; i += 2)
{
bytes[i / 2] = Convert.ToByte(hex.Substring(i, 2), 16);
}
return bytes;
}
// ==================== API调用演示方法 ====================
/// <summary>
/// 演示完整的API调用流程
/// 包括:参数构建、加密、发送请求、接收响应、解密响应
/// </summary>
private static async Task DemonstrateApiCall()
{
try
{
// 构建完整的API URL
string url = $"{BaseUrl}/api/v1/{InterfaceName}";
// 构建请求参数
var apiParams = new
{
mobile_no = TestMobileNo,
id_card = TestIdCard,
auth_date = TestAuthDate,
name = TestName
};
// 将参数转换为JSON字符串并加密
string jsonStr = JsonSerializer.Serialize(apiParams);
Console.WriteLine($"请求参数: {jsonStr}");
string encryptedData = Encrypt(jsonStr);
Console.WriteLine($"加密后的数据: {encryptedData}");
// 构建请求体
var payload = new { data = encryptedData };
string requestBody = JsonSerializer.Serialize(payload);
Console.WriteLine($"发送请求到: {url}");
// 发送HTTP请求
using (var httpClient = new HttpClient())
{
// 设置请求头
httpClient.DefaultRequestHeaders.Add("Access-Id", AccessId);
httpClient.DefaultRequestHeaders.Add("Content-Type", "application/json");
// 设置超时时间
httpClient.Timeout = TimeSpan.FromMilliseconds(RequestTimeout);
var content = new StringContent(requestBody, Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync(url, content);
Console.WriteLine($"响应状态码: {response.StatusCode}");
if (response.IsSuccessStatusCode)
{
string responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($"API响应: {responseBody}");
// 解析响应
try
{
var responseData = JsonSerializer.Deserialize<JsonElement>(responseBody);
if (responseData.TryGetProperty("code", out var codeElement))
{
int code = codeElement.GetInt32();
string message = responseData.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : "";
string encryptedResponse = responseData.TryGetProperty("data", out var dataElement) ? dataElement.GetString() : "";
Console.WriteLine($"API响应码: {code}");
Console.WriteLine($"API消息: {message}");
if (code == 0 && !string.IsNullOrEmpty(encryptedResponse))
{
// 解密响应数据
try
{
string decryptedResponse = Decrypt(encryptedResponse);
Console.WriteLine($"解密后的响应: {decryptedResponse}");
}
catch (Exception ex)
{
Console.WriteLine($"解密响应数据失败: {ex.Message}");
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"解析响应失败: {ex.Message}");
}
}
else
{
Console.WriteLine($"请求失败: {response.StatusCode}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"API调用异常: {ex.Message}");
}
}
}

305
public/examples/go/demo.go Normal file
View File

@@ -0,0 +1,305 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// ==================== 配置区域 ====================
// 请根据实际情况修改以下配置参数
const (
// API接口配置
InterfaceName = "XXXXXXXX" // 接口编号
AccessID = "XXXXXXXXXXX"
EncryptionKey = "XXXXXXXXXXXXXXXXXXXXX"
BaseURL = "https://api.tianyuanapi.com"
// 测试数据配置
TestName = "XXXXXXXX"
TestIDCard = "XXXXXXXXXXXXX"
TestMobileNo = "XXXXXXXXXXXXXXXXXXXX"
TestAuthDate = "20250318-20270318"
// HTTP请求配置
RequestTimeout = 30 * time.Second
)
// API响应结构体
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data string `json:"data"`
}
// 请求参数结构体
type RequestParams struct {
MobileNo string `json:"mobile_no"`
IDCard string `json:"id_card"`
AuthDate string `json:"auth_date"`
Name string `json:"name"`
}
// 请求载荷结构体
type RequestPayload struct {
Data string `json:"data"`
}
// 结果结构体
type Result struct {
Code int `json:"code"`
Success bool `json:"success"`
Message string `json:"message"`
EncryptedResponse string `json:"encrypted_response"`
DecryptedResponse interface{} `json:"decrypted_response"`
}
// ==================== AES加密解密方法 ====================
// AES CBC 加密函数,返回 Base64
func aesEncrypt(plaintext, key string) (string, error) {
keyBytes, err := hex.DecodeString(key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", err
}
// 生成随机IV
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
// 填充数据
paddedData := pkcs7Pad([]byte(plaintext), aes.BlockSize)
// 加密
ciphertext := make([]byte, len(iv)+len(paddedData))
copy(ciphertext, iv)
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(ciphertext[len(iv):], paddedData)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// AES CBC 解密函数,返回解密后的明文
func aesDecrypt(encryptedText, key string) (string, error) {
keyBytes, err := hex.DecodeString(key)
if err != nil {
return "", err
}
ciphertext, err := base64.StdEncoding.DecodeString(encryptedText)
if err != nil {
return "", err
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return "", err
}
if len(ciphertext) < aes.BlockSize {
return "", fmt.Errorf("密文太短")
}
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
if len(ciphertext)%aes.BlockSize != 0 {
return "", fmt.Errorf("密文长度不是块大小的倍数")
}
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(ciphertext, ciphertext)
// 去除填充
unpaddedData, err := pkcs7Unpad(ciphertext)
if err != nil {
return "", err
}
return string(unpaddedData), nil
}
// PKCS7填充
func pkcs7Pad(data []byte, blockSize int) []byte {
padding := blockSize - len(data)%blockSize
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
return append(data, padtext...)
}
// PKCS7去除填充
func pkcs7Unpad(data []byte) ([]byte, error) {
length := len(data)
if length == 0 {
return nil, fmt.Errorf("数据为空")
}
unpadding := int(data[length-1])
if unpadding > length {
return nil, fmt.Errorf("无效的填充")
}
return data[:length-unpadding], nil
}
// ==================== API调用方法 ====================
// 调用API函数
func callAPI(name, idCard, mobileNo, authDate string) *Result {
// 构建完整的API URL
url := fmt.Sprintf("%s/api/v1/%s", BaseURL, InterfaceName)
// 构建请求参数
params := RequestParams{
MobileNo: mobileNo,
IDCard: idCard,
AuthDate: authDate,
Name: name,
}
// 将参数转换为JSON字符串并加密
jsonStr, err := json.Marshal(params)
if err != nil {
return &Result{
Success: false,
Message: fmt.Sprintf("JSON序列化失败: %v", err),
}
}
fmt.Printf("请求参数: %s\n", string(jsonStr))
encryptedData, err := aesEncrypt(string(jsonStr), EncryptionKey)
if err != nil {
return &Result{
Success: false,
Message: fmt.Sprintf("加密失败: %v", err),
}
}
fmt.Printf("加密后的数据: %s\n", encryptedData)
// 构建请求载荷
payload := RequestPayload{
Data: encryptedData,
}
// 序列化请求载荷
requestBody, err := json.Marshal(payload)
if err != nil {
return &Result{
Success: false,
Message: fmt.Sprintf("请求载荷序列化失败: %v", err),
}
}
fmt.Printf("发送请求到: %s\n", url)
// 发送HTTP请求
client := &http.Client{
Timeout: RequestTimeout,
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody))
if err != nil {
return &Result{
Success: false,
Message: fmt.Sprintf("创建请求失败: %v", err),
}
}
// 设置请求头
req.Header.Set("Access-Id", AccessID)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return &Result{
Success: false,
Message: fmt.Sprintf("请求失败: %v", err),
}
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return &Result{
Success: false,
Message: fmt.Sprintf("读取响应失败: %v", err),
}
}
// 解析响应
var apiResp APIResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return &Result{
Success: false,
Message: fmt.Sprintf("解析响应失败: %v", err),
}
}
fmt.Printf("API响应: %s\n", string(respBody))
// 处理响应
result := &Result{
Code: apiResp.Code,
Success: apiResp.Code == 0,
Message: apiResp.Message,
EncryptedResponse: apiResp.Data,
}
// 如果有返回data尝试解密
if apiResp.Data != "" {
decryptedData, err := aesDecrypt(apiResp.Data, EncryptionKey)
if err != nil {
fmt.Printf("解密响应数据失败: %v\n", err)
result.DecryptedResponse = nil
} else {
var decryptedJSON interface{}
if err := json.Unmarshal([]byte(decryptedData), &decryptedJSON); err != nil {
fmt.Printf("解析解密后的JSON失败: %v\n", err)
result.DecryptedResponse = decryptedData
} else {
result.DecryptedResponse = decryptedJSON
}
}
}
return result
}
// ==================== 主程序 ====================
func main() {
fmt.Println("===== 个人涉诉详版 =====")
// 调用API
result := callAPI(TestName, TestIDCard, TestMobileNo, TestAuthDate)
fmt.Println("\n===== 结果 =====")
if result.Success {
fmt.Println("请求成功!")
if result.DecryptedResponse != nil {
decryptedJSON, _ := json.MarshalIndent(result.DecryptedResponse, "", " ")
fmt.Printf("解密后的响应: %s\n", string(decryptedJSON))
} else {
fmt.Println("未能获取或解密响应数据")
}
} else {
fmt.Printf("请求失败: %s\n", result.Message)
}
}

View File

@@ -0,0 +1,129 @@
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Random;
import java.io.OutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import org.json.JSONObject;
public class Demo {
// 加密
public static String aesEncrypt(String plainText, String hexKey) throws Exception {
byte[] keyBytes = hexStringToByteArray(hexKey);
byte[] iv = new byte[16];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
// 拼接 IV 和密文
byte[] encryptedData = new byte[iv.length + encrypted.length];
System.arraycopy(iv, 0, encryptedData, 0, iv.length);
System.arraycopy(encrypted, 0, encryptedData, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(encryptedData);
}
// 解密
public static String aesDecrypt(String encryptedText, String hexKey) throws Exception {
byte[] encryptedBytes = Base64.getDecoder().decode(encryptedText);
byte[] keyBytes = hexStringToByteArray(hexKey);
byte[] iv = new byte[16];
System.arraycopy(encryptedBytes, 0, iv, 0, 16);
byte[] encryptedData = new byte[encryptedBytes.length - 16];
System.arraycopy(encryptedBytes, 16, encryptedData, 0, encryptedData.length);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv);
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
byte[] decrypted = cipher.doFinal(encryptedData);
return new String(decrypted, StandardCharsets.UTF_8);
}
// Helper 方法,将 16 进制字符串转为字节数组
public static byte[] hexStringToByteArray(String s) {
int len = s.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) + Character.digit(s.charAt(i + 1), 16));
}
return data;
}
// 发送 HTTP POST 请求
public static String sendPostRequest(String urlString, String data, String accessId) throws Exception {
URL url = new URL(urlString);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Access-Id", accessId);
connection.setDoOutput(true);
// 构造请求体
String jsonInputString = "{\"data\":\"" + data + "\"}";
try (OutputStream os = connection.getOutputStream()) {
byte[] input = jsonInputString.getBytes("utf-8");
os.write(input, 0, input.length);
}
// 读取响应
BufferedReader br = new BufferedReader(new InputStreamReader(connection.getInputStream(), "utf-8"));
StringBuilder response = new StringBuilder();
String responseLine;
while ((responseLine = br.readLine()) != null) {
response.append(responseLine.trim());
}
return response.toString();
}
public static void main(String[] args) {
try {
// 设置 URL、密钥和请求参数
String url = "https://api.tianyuanapi.com/api/v1/IVYZ5733";
String accessId = "XXXXXXXXXXXXX";
String key = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
// 请求参数
JSONObject requestParams = new JSONObject();
requestParams.put("name", "李四");
requestParams.put("id_card", "110101199003076534");
// 将请求参数转为 JSON 字符串
String jsonStr = requestParams.toString();
// 加密请求数据
String encryptedData = aesEncrypt(jsonStr, key);
// 发送 HTTP POST 请求并获取响应
String response = sendPostRequest(url, encryptedData, accessId);
// 解析响应数据
JSONObject responseData = new JSONObject(response);
String encryptedResponseData = responseData.optString("data");
// 解密返回的加密数据
if (encryptedResponseData != null) {
String decryptedResponseData = aesDecrypt(encryptedResponseData, key);
System.out.println("Decrypted Response Data: " + decryptedResponseData);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

View File

@@ -0,0 +1,11 @@
# ESLint 忽略配置 - 忽略示例代码目录下的所有语法错误
# 这些是示例代码文件,不需要进行 ESLint 语法检查
# 忽略当前目录下的所有 .js 文件
*.js
# 忽略当前目录下的所有文件
*
# 忽略子目录
*/

View File

@@ -0,0 +1,134 @@
#!/usr/bin/env node
const crypto = require("crypto");
// ==================== API配置 ====================
const ACCESS_ID = "XXXXXXXXX"; // 替换为您的ACCESS_ID
const APP_SECRET = "XXXXXXXXXXXXXXXXXXXXXXX"; // 替换为您的app_secret
const BASE_URL = 'https://api.tianyuanapi.com';
// ==================== 测试参数 ====================
const API_CODE = "FLXG0V4B"; // 替换为您的API编号
const PARAMS = {
"name": "XXXX",
"id_card": "XXXXXXXXXXXXXXX",
"auth_date": "20250722-20250923"
};
// ==================== 加密解密函数 ====================
function encrypt_data(data) {
const key = Buffer.from(APP_SECRET, "hex");
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
cipher.setAutoPadding(true);
let encrypted = cipher.update(data, 'utf8');
encrypted = Buffer.concat([iv, encrypted, cipher.final()]);
return encrypted.toString("base64");
}
function decrypt_data(encrypted_data) {
const key = Buffer.from(APP_SECRET, "hex");
const encryptedBuffer = Buffer.from(encrypted_data, "base64");
const iv = encryptedBuffer.slice(0, 16);
const ciphertext = encryptedBuffer.slice(16);
const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
decipher.setAutoPadding(true);
let decrypted = decipher.update(ciphertext);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
// ==================== 主测试函数 ====================
async function test_api() {
try {
console.log(`=== 测试API: ${API_CODE} ===`);
console.log(`请求参数: ${JSON.stringify(PARAMS, null, 2)}`);
// 加密参数
const params_json = JSON.stringify(PARAMS);
const encrypted_data = encrypt_data(params_json);
// 构建请求
const headers = {
'Content-Type': 'application/json',
'Access-Id': ACCESS_ID
};
const url = `${BASE_URL}/api/v1/${API_CODE}`;
const request_data = { 'data': encrypted_data, 'options': { 'json': true } };
console.log(`请求URL: ${url}`);
console.log(`请求头: ${JSON.stringify(headers, null, 2)}`);
// 发送请求
const start_time = Date.now();
const response = await fetch(url, {
method: 'POST',
headers: headers,
body: JSON.stringify(request_data),
signal: AbortSignal.timeout(30000) // 30秒超时
});
const elapsed_time = Date.now() - start_time;
console.log(`\n=== 响应信息 ===`);
console.log(`状态码: ${response.status}`);
console.log(`耗时: ${elapsed_time}ms`);
if (response.status !== 200) {
console.log(`请求失败: ${response.status} ${response.statusText}`);
return;
}
// 解析响应
try {
const response_json = await response.json();
console.log(`原始响应: ${JSON.stringify(response_json, null, 2)}`);
// 检查响应格式
if (!('code' in response_json)) {
console.log("直接返回业务数据");
return;
}
// 标准格式处理
const api_code = response_json.code;
const api_message = response_json.message || '';
const encrypted_response = response_json.data || '';
console.log(`API响应码: ${api_code}`);
console.log(`API消息: ${api_message}`);
if (api_code !== 0) {
console.log(`API错误: ${api_message}`);
return;
}
if (!encrypted_response) {
console.log("无加密数据返回");
return;
}
// 解密数据
try {
const decrypted_data = decrypt_data(encrypted_response);
const result_data = JSON.parse(decrypted_data);
console.log(`\n=== 解密后的数据 ===`);
console.log(JSON.stringify(result_data, null, 2));
} catch (e) {
console.log(`解密失败: ${e.message}`);
}
} catch (e) {
console.log(`响应不是JSON格式: ${e.message}`);
}
} catch (e) {
console.log(`测试异常: ${e.message}`);
}
}
// 运行测试
test_api();

View File

@@ -0,0 +1,176 @@
<?php
/**
* AES CBC 加密函数,返回 Base64
* @param string $plainText 要加密的明文
* @param string $key 16进制密钥
* @return string 加密后的Base64字符串
*/
function aesEncrypt($plainText, $key) {
// 将16进制的密钥转换为二进制
$keyBin = hex2bin($key);
// 生成随机IV
$blockSize = 16; // AES 块大小
$iv = openssl_random_pseudo_bytes($blockSize);
// 加密
$encrypted = openssl_encrypt(
$plainText,
'AES-128-CBC',
$keyBin,
OPENSSL_RAW_DATA,
$iv
);
// 将IV和加密数据拼接后进行Base64编码
return base64_encode($iv . $encrypted);
}
/**
* AES CBC 解密函数,返回解密后的明文
* @param string $encryptedText Base64编码的加密文本
* @param string $key 16进制密钥
* @return string 解密后的明文
*/
function aesDecrypt($encryptedText, $key) {
// 将16进制的密钥转换为二进制
$keyBin = hex2bin($key);
// 解码Base64
$encryptedBin = base64_decode($encryptedText);
// 提取IV和加密数据
$blockSize = 16;
$iv = substr($encryptedBin, 0, $blockSize);
$encryptedData = substr($encryptedBin, $blockSize);
// 解密
$decrypted = openssl_decrypt(
$encryptedData,
'AES-128-CBC',
$keyBin,
OPENSSL_RAW_DATA,
$iv
);
return $decrypted;
}
/**
* 调用API函数
*/
function callApi($name, $id_card, $mobile_no, $auth_date) {
// API相关配置
$interface_name = "XXXXXXXX"; // 接口编号
$access_id = "XXXXXXXXXXX";
$key = "XXXXXXXXXXXXXXXXXXXXX";
$url = "https://api.tianyuanapi.com/api/v1/{$interface_name}";
// 构建请求参数
$params = array(
"mobile_no" => $mobile_no,
"id_card" => $id_card,
"auth_date" => $auth_date,
"name" => $name
);
// 将参数转换为JSON字符串并加密
$json_str = json_encode($params, JSON_UNESCAPED_UNICODE);
echo "请求参数: {$json_str}\n";
$encrypted_data = aesEncrypt($json_str, $key);
echo "加密后的数据: {$encrypted_data}\n";
// 发送请求
$headers = array(
"Access-Id: {$access_id}",
"Content-Type: application/json"
);
$payload = array(
"data" => $encrypted_data
);
echo "发送请求到: {$url}\n";
try {
// 使用PHP原生HTTP请求方式
$context = stream_context_create(array(
'http' => array(
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => json_encode($payload),
'timeout' => 30
)
));
$response = file_get_contents($url, false, $context);
if ($response === false) {
throw new Exception("HTTP请求失败");
}
$response_data = json_decode($response, true);
echo "API响应: " . json_encode($response_data, JSON_UNESCAPED_UNICODE) . "\n";
// 处理响应
$code = $response_data['code'] ?? null;
$message = $response_data['message'] ?? '';
$encrypted_response_data = $response_data['data'] ?? '';
$result = array(
"code" => $code,
"success" => $code == 0,
"message" => $message,
"encrypted_response" => $encrypted_response_data
);
// 如果有返回data尝试解密
if ($encrypted_response_data) {
try {
$decrypted_data = aesDecrypt($encrypted_response_data, $key);
$result["decrypted_response"] = json_decode($decrypted_data, true);
} catch (Exception $e) {
echo "解密响应数据失败: {$e->getMessage()}\n";
$result["decrypted_response"] = null;
}
}
return $result;
} catch (Exception $e) {
echo "请求失败: {$e->getMessage()}\n";
return array("success" => false, "message" => "请求失败: {$e->getMessage()}");
}
}
/**
* 主函数
*/
function main() {
echo "===== 个人涉诉详版 =====\n";
// 直接设置手机号和姓名
$name = "XXXXXXXX";
$id_card = "XXXXXXXXXXXXX";
$mobile_no = "XXXXXXXXXXXXXXXXXXXX";
$auth_date = "20250318-20270318";
$result = callApi($name, $id_card, $mobile_no, $auth_date);
echo "\n===== 结果 =====\n";
if ($result["success"]) {
echo "请求成功!\n";
if (isset($result["decrypted_response"])) {
echo "解密后的响应: " . json_encode($result['decrypted_response'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
} else {
echo "未能获取或解密响应数据\n";
}
} else {
echo "请求失败: " . ($result['message'] ?? '未知错误') . "\n";
}
}
// 运行主函数
main();
?>

View File

@@ -0,0 +1,124 @@
import requests
import json
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
import os
# AES CBC 加密函数,返回 Base64
def aes_encrypt(plaintext, key):
# 将16进制密钥转换为字节
key_bytes = bytes.fromhex(key)
# 生成随机IV
iv = os.urandom(16)
# 创建加密器
cipher = AES.new(key_bytes, AES.MODE_CBC, iv)
# 对明文进行填充并加密
padded_data = pad(plaintext.encode('utf-8'), AES.block_size)
encrypted_data = cipher.encrypt(padded_data)
# 连接IV和加密后的数据
result = iv + encrypted_data
# 转换为Base64编码
return base64.b64encode(result).decode('utf-8')
# AES CBC 解密函数,返回解密后的明文
def aes_decrypt(encrypted_text, key):
# 将Base64编码的加密数据转换为字节
encrypted_bytes = base64.b64decode(encrypted_text)
# 将16进制密钥转换为字节
key_bytes = bytes.fromhex(key)
# 从加密数据中提取IV和加密数据
iv = encrypted_bytes[:16]
encrypted_data = encrypted_bytes[16:]
# 创建解密器
decipher = AES.new(key_bytes, AES.MODE_CBC, iv)
# 解密并去除填充
padded_data = decipher.decrypt(encrypted_data)
return unpad(padded_data, AES.block_size).decode('utf-8')
def CallApi(name,id_card,mobile_no,auth_date):
# API相关配置
interface_name = "XXXXXXXX" #接口编号
access_id = "XXXXXXXXXXX"
key = "XXXXXXXXXXXXXXXXXXXXX"
url = f"https://api.tianyuanapi.com/api/v1/{interface_name}"
# 构建请求参数
params = {
"mobile_no": mobile_no,
"id_card": id_card,
"auth_date": auth_date,
"name": name,
}
# 将参数转换为JSON字符串并加密
json_str = json.dumps(params)
print(f"请求参数: {json_str}")
encrypted_data = aes_encrypt(json_str, key)
print(f"加密后的数据: {encrypted_data}")
# 发送请求
headers = {
"Access-Id": access_id,
"Content-Type": "application/json"
}
payload = {
"data": encrypted_data
}
print(f"发送请求到: {url}")
try:
response = requests.post(url, json=payload, headers=headers)
response_data = response.json()
print(f"API响应: {response_data}")
# 处理响应
code = response_data.get("code")
message = response_data.get("message")
encrypted_response_data = response_data.get("data")
result = {
"code": code,
"success": code == 0,
"message": message,
"encrypted_response": encrypted_response_data
}
# 如果有返回data尝试解密
if encrypted_response_data:
try:
decrypted_data = aes_decrypt(encrypted_response_data, key)
result["decrypted_response"] = json.loads(decrypted_data)
except Exception as e:
print(f"解密响应数据失败: {e}")
result["decrypted_response"] = None
return result
except Exception as e:
print(f"请求失败: {e}")
return {"success": False, "message": f"请求失败: {e}"}
def main():
print("===== 个人涉诉详版 =====")
# 直接设置手机号和姓名
name = "XXXXXXXX"
id_card = "XXXXXXXXXXXXX"
mobile_no = "XXXXXXXXXXXXXXXXXXXX"
auth_date = "20250318-20270318"
result = CallApi(name,id_card,mobile_no,auth_date)
print("\n===== 结果 =====")
if result["success"]:
print("请求成功!")
if result.get("decrypted_response"):
print(f"解密后的响应: {json.dumps(result['decrypted_response'], ensure_ascii=False, indent=2)}")
else:
print("未能获取或解密响应数据")
else:
print(f"请求失败: {result.get('message', '未知错误')}")
if __name__ == "__main__":
main()

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/qrcode.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
public/yhxy.pdf Normal file

Binary file not shown.

BIN
public/yszc.pdf Normal file

Binary file not shown.

93
public/zh_CN.js Normal file
View File

@@ -0,0 +1,93 @@
/*!
* TinyMCE
*
* Copyright (c) 2025 Ephox Corporation DBA Tiny Technologies, Inc.
* Licensed under the Tiny commercial license. See https://www.tiny.cloud/legal/
*/
tinymce.Resource.add('tinymce.html-i18n.help-keynav.zh_CN',
'<h1>开始键盘导航</h1>\n' +
'\n' +
'<dl>\n' +
' <dt>使菜单栏处于焦点</dt>\n' +
' <dd>Windows 或 LinuxAlt+F9</dd>\n' +
' <dd>macOS&#x2325;F9</dd>\n' +
' <dt>使工具栏处于焦点</dt>\n' +
' <dd>Windows 或 LinuxAlt+F10</dd>\n' +
' <dd>macOS&#x2325;F10</dd>\n' +
' <dt>使页脚处于焦点</dt>\n' +
' <dd>Windows 或 LinuxAlt+F11</dd>\n' +
' <dd>macOS&#x2325;F11</dd>\n' +
' <dt>使通知处于焦点</dt>\n' +
' <dd>Windows 或 LinuxAlt+F12</dd>\n' +
' <dd>macOS&#x2325;F12</dd>\n' +
' <dt>使上下文工具栏处于焦点</dt>\n' +
' <dd>Windows、Linux 或 macOSCtrl+F9</dd>\n' +
'</dl>\n' +
'\n' +
'<p>导航将在第一个 UI 项上开始,其中突出显示该项,或者对于页脚元素路径中的第一项,将为其添加下划线。</p>\n' +
'\n' +
'<h1>在 UI 部分之间导航</h1>\n' +
'\n' +
'<p>要从一个 UI 部分移至下一个,请按 <strong>Tab</strong>。</p>\n' +
'\n' +
'<p>要从一个 UI 部分移至上一个,请按 <strong>Shift+Tab</strong>。</p>\n' +
'\n' +
'<p>这些 UI 部分的 <strong>Tab</strong> 顺序为:</p>\n' +
'\n' +
'<ol>\n' +
' <li>菜单栏</li>\n' +
' <li>每个工具栏组</li>\n' +
' <li>边栏</li>\n' +
' <li>页脚中的元素路径</li>\n' +
' <li>页脚中的字数切换按钮</li>\n' +
' <li>页脚中的品牌链接</li>\n' +
' <li>页脚中的编辑器调整大小图柄</li>\n' +
'</ol>\n' +
'\n' +
'<p>如果不存在某个 UI 部分,则跳过它。</p>\n' +
'\n' +
'<p>如果键盘导航焦点在页脚,并且没有可见的边栏,则按 <strong>Shift+Tab</strong> 将焦点移至第一个工具栏组而非最后一个。</p>\n' +
'\n' +
'<h1>在 UI 部分内导航</h1>\n' +
'\n' +
'<p>要从一个 UI 元素移至下一个,请按相应的<strong>箭头</strong>键。</p>\n' +
'\n' +
'<p><strong>左</strong>和<strong>右</strong>箭头键</p>\n' +
'\n' +
'<ul>\n' +
' <li>在菜单栏中的菜单之间移动。</li>\n' +
' <li>打开菜单中的子菜单。</li>\n' +
' <li>在工具栏组中的按钮之间移动。</li>\n' +
' <li>在页脚的元素路径中的各项之间移动。</li>\n' +
'</ul>\n' +
'\n' +
'<p><strong>下</strong>和<strong>上</strong>箭头键</p>\n' +
'\n' +
'<ul>\n' +
' <li>在菜单中的菜单项之间移动。</li>\n' +
' <li>在工具栏弹出菜单中的各项之间移动。</li>\n' +
'</ul>\n' +
'\n' +
'<p><strong>箭头</strong>键在具有焦点的 UI 部分内循环。</p>\n' +
'\n' +
'<p>要关闭打开的菜单、打开的子菜单或打开的弹出菜单,请按 <strong>Esc</strong> 键。</p>\n' +
'\n' +
'<p>如果当前的焦点在特定 UI 部分的“顶部”,则按 <strong>Esc</strong> 键还将完全退出键盘导航。</p>\n' +
'\n' +
'<h1>执行菜单项或工具栏按钮</h1>\n' +
'\n' +
'<p>当突出显示所需的菜单项或工具栏按钮时,按 <strong>Return</strong>、<strong>Enter</strong> 或<strong>空格</strong>以执行该项。</p>\n' +
'\n' +
'<h1>在非标签页式对话框中导航</h1>\n' +
'\n' +
'<p>在非标签页式对话框中,当对话框打开时,第一个交互组件获得焦点。</p>\n' +
'\n' +
'<p>通过按 <strong>Tab</strong> 或 <strong>Shift+Tab</strong>,在交互对话框组件之间导航。</p>\n' +
'\n' +
'<h1>在标签页式对话框中导航</h1>\n' +
'\n' +
'<p>在标签页式对话框中,当对话框打开时,标签页菜单中的第一个按钮获得焦点。</p>\n' +
'\n' +
'<p>通过按 <strong>Tab</strong> 或 <strong>Shift+Tab</strong>,在此对话框的交互组件之间导航。</p>\n' +
'\n' +
'<p>通过将焦点移至另一对话框标签页的菜单,然后按相应的<strong>箭头</strong>键以在可用的标签页间循环,从而切换到该对话框标签页。</p>\n');

37
src/App.vue Normal file
View File

@@ -0,0 +1,37 @@
<script setup>
import AppLoading from '@/components/common/AppLoading.vue'
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
const appStore = useAppStore()
const userStore = useUserStore()
// 计算加载状态
const isLoading = computed(() => !userStore.initialized)
onMounted(async () => {
// 初始化应用配置
appStore.initAppConfig()
// 初始化用户store
await userStore.init()
})
onUnmounted(() => {
// 清理用户store
userStore.cleanup()
})
</script>
<template>
<div id="app">
<AppLoading v-model:visible="isLoading" />
<router-view v-if="userStore.initialized" />
</div>
</template>
<style>
#app {
min-height: 100vh;
}
</style>

45
src/api/article.js Normal file
View File

@@ -0,0 +1,45 @@
import request from '@/utils/request'
// 文章管理API
export const articleApi = {
// ==================== 用户端API ====================
// 文章查询
getArticles: (params) => request.get('/articles', { params }),
getArticleDetail: (id) => request.get(`/articles/${id}`),
// 分类查询
getCategories: () => request.get('/article-categories'),
getCategoryDetail: (id) => request.get(`/article-categories/${id}`),
// 标签查询
getTags: () => request.get('/article-tags'),
getTagDetail: (id) => request.get(`/article-tags/${id}`),
// ==================== 管理员端API ====================
// 统计信息
getArticleStats: () => request.get('/admin/articles/stats'),
// 文章管理
getArticlesForAdmin: (params) => request.get('/admin/articles', { params }),
createArticle: (data) => request.post('/admin/articles', data),
updateArticle: (id, data) => request.put(`/admin/articles/${id}`, data),
deleteArticle: (id) => request.delete(`/admin/articles/${id}`),
// 文章状态管理
publishArticle: (id) => request.post(`/admin/articles/${id}/publish`),
schedulePublishArticle: (id, data) => request.post(`/admin/articles/${id}/schedule-publish`, data),
updateSchedulePublishArticle: (id, data) => request.post(`/admin/articles/${id}/update-schedule-publish`, data),
cancelSchedulePublishArticle: (id) => request.post(`/admin/articles/${id}/cancel-schedule`),
archiveArticle: (id) => request.post(`/admin/articles/${id}/archive`),
setFeatured: (id, data) => request.put(`/admin/articles/${id}/featured`, data),
// 分类管理
createCategory: (data) => request.post('/admin/article-categories', data),
updateCategory: (id, data) => request.put(`/admin/article-categories/${id}`, data),
deleteCategory: (id) => request.delete(`/admin/article-categories/${id}`),
// 标签管理
createTag: (data) => request.post('/admin/article-tags', data),
updateTag: (id, data) => request.put(`/admin/article-tags/${id}`, data),
deleteTag: (id) => request.delete(`/admin/article-tags/${id}`)
}

View File

@@ -0,0 +1,56 @@
import request from '@/utils/request'
/**
* 余额预警API接口
* 注意:这些接口需要后端实现
*/
// 获取用户余额预警设置
export const getUserAlertSettings = () => {
return request({
url: '/user/balance-alert/settings',
method: 'get'
})
}
// 更新用户余额预警设置
export const updateUserAlertSettings = (data) => {
return request({
url: '/user/balance-alert/settings',
method: 'put',
data
})
}
// 测试预警短信发送
export const testAlertSms = (data) => {
return request({
url: '/user/balance-alert/test-sms',
method: 'post',
data
})
}
// 获取预警历史记录
export const getAlertHistory = (params) => {
return request({
url: '/user/balance-alert/history',
method: 'get',
params
})
}
// 命名导出
export const balanceAlertApi = {
getUserAlertSettings,
updateUserAlertSettings,
testAlertSms,
getAlertHistory
}
export default {
getUserAlertSettings,
updateUserAlertSettings,
testAlertSms,
getAlertHistory
}

306
src/api/index.js Normal file
View File

@@ -0,0 +1,306 @@
import request from '@/utils/request'
import { articleApi } from './article.js'
import { balanceAlertApi } from './balanceAlertApi.js'
import { adminInvoiceApi, invoiceApi } from './invoice.js'
// 直接导出发票API、文章API和余额预警API
export { adminInvoiceApi, articleApi, balanceAlertApi, invoiceApi }
// 用户相关接口 - 严格按照后端路由定义
export const userApi = {
// 发送验证码
sendCode: (data) => request.post('/users/send-code', data),
// 用户注册
register: (data) => request.post('/users/register', data),
// 密码登录
loginWithPassword: (data) => request.post('/users/login-password', data),
// 短信登录
loginWithSMS: (data) => request.post('/users/login-sms', data),
// 获取用户信息 (需认证)
getProfile: () => request.get('/users/me'),
// 修改密码 (需认证)
changePassword: (data) => request.put('/users/me/password', data),
// 重置密码 (无需认证)
resetPassword: (data) => request.post('/users/reset-password', data),
// 管理员功能 - 获取用户列表 (需管理员权限)
getUserList: (params) => request.get('/users/admin/list', { params }),
// 管理员功能 - 获取用户详情 (需管理员权限)
getUserDetail: (userId) => request.get(`/users/admin/${userId}`),
// 管理员功能 - 获取用户统计信息 (需管理员权限)
getUserStats: () => request.get('/users/admin/stats')
}
// 产品相关接口
export const productApi = {
// 产品列表(用户端接口)
getProducts: (params) => request.get('/products', { params }),
getProductDetail: (id, params) => request.get(`/products/${id}`, { params }),
searchProducts: (params) => request.get('/products/search', { params }),
// 产品分类
getCategories: (params) => request.get('/categories', { params }),
// 订阅产品
subscribeProduct: (productId) => request.post(`/products/${productId}/subscribe`),
// 产品API配置
getProductApiConfig: (productId) => request.get(`/products/${productId}/api-config`),
getProductApiConfigByCode: (productCode) => request.get(`/products/code/${productCode}/api-config`),
getProductApiConfigsByProductIDs: (productIds) => request.get('/products/api-configs', {
params: { product_ids: productIds.join(',') }
})
}
// 分类相关接口 - 数据大厅
export const categoryApi = {
// 获取分类列表 (公开接口)
getCategories: (params) => request.get('/categories', { params }),
// 获取分类详情 (公开接口)
getCategoryDetail: (id) => request.get(`/categories/${id}`)
}
// 订阅相关接口 - 我的订阅
export const subscriptionApi = {
// 获取我的订阅列表 (需认证)
getMySubscriptions: (params) => request.get('/my/subscriptions', { params }),
// 获取我的订阅统计 (需认证)
getMySubscriptionStats: () => request.get('/my/subscriptions/stats'),
// 获取我的订阅详情 (需认证)
getMySubscriptionDetail: (id) => request.get(`/my/subscriptions/${id}`),
// 获取我的订阅使用情况 (需认证)
getMySubscriptionUsage: (id) => request.get(`/my/subscriptions/${id}/usage`)
}
// 财务相关接口
export const financeApi = {
// 钱包相关
createWallet: (data) => request.post('/finance/wallet', data),
getWallet: () => request.get('/finance/wallet'),
updateWallet: (data) => request.put('/finance/wallet', data),
rechargeWallet: (data) => request.post('/finance/wallet/recharge', data),
withdrawWallet: (data) => request.post('/finance/wallet/withdraw', data),
walletTransaction: (data) => request.post('/finance/wallet/transaction', data),
getWalletStats: () => request.get('/finance/wallet/stats'),
getRechargeConfig: () => request.get('/finance/wallet/recharge-config'),
// 充值相关
transferRecharge: (data) => request.post('/finance/wallet/transfer-recharge', data),
giftRecharge: (data) => request.post('/finance/wallet/gift-recharge', data),
createAlipayRecharge: (data) => request.post('/finance/wallet/alipay-recharge', data),
// 用户密钥相关
createUserSecrets: (data) => request.post('/finance/secrets', data),
getUserSecrets: () => request.get('/finance/secrets'),
regenerateAccessKey: () => request.post('/finance/secrets/regenerate'),
deactivateUserSecrets: () => request.post('/finance/secrets/deactivate'),
// 钱包交易记录
getUserWalletTransactions: (params) => request.get('/finance/wallet/transactions', { params }),
// 支付宝订单状态查询
getAlipayOrderStatus: (params) => request.get('/finance/wallet/alipay-order-status', { params }),
// 管理员充值功能
transferRecharge: (data) => request.post('/admin/finance/transfer-recharge', data),
giftRecharge: (data) => request.post('/admin/finance/gift-recharge', data),
// 充值记录相关接口
getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }),
getAdminRechargeRecords: (params) => request.get('/admin/finance/recharge-records', { params })
}
// 认证相关接口
export const certificationApi = {
// 获取认证详情
getCertificationDetails: () => request.get('/certifications/details'),
// 获取认证进度
getCertificationProgress: () => request.get('/certifications/progress'),
// 提交企业信息
submitEnterpriseInfo: (data) => request.post('/certifications/enterprise-info', data),
// 发起人脸识别验证
initiateFaceVerify: (data) => request.post('/certifications/face-verify', data),
// 申请合同签署
applyContract: () => request.post('/certifications/apply-contract'),
// 获取认证详情
getCertificationDetails: () => request.get('/certifications/details'),
// 确认签署状态
confirmSign: (data) => request.post('/certifications/confirm-sign', data),
// 确认认证状态
confirmAuth: (data) => request.post('/certifications/confirm-auth', data),
// OCR营业执照识别
recognizeBusinessLicense: (formData) => request.post('/certifications/ocr/business-license', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// API相关接口
export const apiApi = {
// 用户API调用记录
getUserApiCalls: (params) => request.get('/my/api-calls', { params }),
// 加密参数接口(用于前端调试)
encryptParams: (data, secretKey) => request.post('/encrypt', { data, secret_key: secretKey }),
// 解密参数接口(用于前端调试)
decryptParams: (encryptedData, secretKey) => request.post('/decrypt', { encrypted_data: encryptedData, secret_key: secretKey })
}
// API调用记录API
export const apiCallApi = {
// 用户端API调用记录
getUserApiCalls: (params) => request.get('/my/api-calls', { params }),
// 管理端API调用记录
getAdminApiCalls: (params) => request.get('/admin/api-calls', { params }),
// 管理端导出API调用记录
exportAdminApiCalls: (params) => request.get('/admin/api-calls/export', {
params,
responseType: 'blob'
})
}
// 钱包交易记录API
export const walletTransactionApi = {
// 用户端消费记录
getUserWalletTransactions: (params) => request.get('/finance/wallet/transactions', { params }),
// 管理端消费记录
getAdminWalletTransactions: (params) => request.get('/admin/wallet-transactions', { params }),
// 管理端导出消费记录
exportAdminWalletTransactions: (params) => request.get('/admin/wallet-transactions/export', {
params,
responseType: 'blob'
})
}
// 充值记录API
export const rechargeRecordApi = {
// 用户端充值记录
getUserRechargeRecords: (params) => request.get('/finance/wallet/recharge-records', { params }),
// 管理端充值记录
getAdminRechargeRecords: (params) => request.get('/admin/recharge-records', { params }),
// 管理端导出充值记录
exportAdminRechargeRecords: (params) => request.get('/admin/recharge-records/export', {
params,
responseType: 'blob'
})
}
// API密钥相关接口
export const apiKeysApi = {
// 获取用户API密钥 (需认证)
getUserApiKeys: () => request.get('/api-keys')
}
export const whiteListApi = {
// 获取用户白名单列表 (需认证)
getWhiteList: () => request.get('/white-list'),
// 添加白名单IP (需认证)
addWhiteListIP: (ipAddress) => request.post('/white-list', { ip_address: ipAddress }),
// 删除白名单IP (需认证)
deleteWhiteListIP: (ipAddress) => request.delete(`/white-list/${encodeURIComponent(ipAddress)}`)
}
// 管理员接口 - 需要管理员权限
export const productAdminApi = {
// 产品管理
getProducts: (params) => request.get('/admin/products', { params }),
getProductDetail: (id) => request.get(`/admin/products/${id}`),
createProduct: (data) => request.post('/admin/products', data),
updateProduct: (id, data) => request.put(`/admin/products/${id}`, data),
deleteProduct: (id) => request.delete(`/admin/products/${id}`),
// 组合包管理
getAvailableProducts: (params) => request.get('/admin/products/available', { params }),
addPackageItem: (packageId, data) => request.post(`/admin/products/${packageId}/package-items`, data),
updatePackageItem: (packageId, itemId, data) => request.put(`/admin/products/${packageId}/package-items/${itemId}`, data),
removePackageItem: (packageId, itemId) => request.delete(`/admin/products/${packageId}/package-items/${itemId}`),
reorderPackageItems: (packageId, data) => request.put(`/admin/products/${packageId}/package-items/reorder`, data),
updatePackageItems: (packageId, data) => request.put(`/admin/products/${packageId}/package-items/batch`, data),
// 分类管理
getCategories: (params) => request.get('/admin/product-categories', { params }),
getCategoryDetail: (id) => request.get(`/admin/product-categories/${id}`),
createCategory: (data) => request.post('/admin/product-categories', data),
updateCategory: (id, data) => request.put(`/admin/product-categories/${id}`, data),
deleteCategory: (id) => request.delete(`/admin/product-categories/${id}`),
// 订阅管理
getSubscriptions: (params) => request.get('/admin/subscriptions', { params }),
getSubscriptionStats: () => request.get('/admin/subscriptions/stats'),
updateSubscriptionPrice: (id, data) => request.put(`/admin/subscriptions/${id}/price`, data),
batchUpdateSubscriptionPrices: (data) => request.post('/admin/subscriptions/batch-update-prices', data),
// 产品API配置管理
getProductApiConfig: (productId) => request.get(`/admin/products/${productId}/api-config`),
createProductApiConfig: (productId, data) => request.post(`/admin/products/${productId}/api-config`, data),
updateProductApiConfig: (productId, data) => request.put(`/admin/products/${productId}/api-config`, data),
deleteProductApiConfig: (productId) => request.delete(`/admin/products/${productId}/api-config`),
// 产品文档管理
getProductDocumentation: (productId) => request.get(`/admin/products/${productId}/documentation`),
createProductDocumentation: (productId, data) => request.post(`/admin/products/${productId}/documentation`, data),
updateProductDocumentation: (productId, data) => request.put(`/admin/products/${productId}/documentation`, data),
deleteProductDocumentation: (productId) => request.delete(`/admin/products/${productId}/documentation`)
}
// 表单配置相关接口
export const formConfigApi = {
// 获取指定API的表单配置
getFormConfig: (apiCode) => request.get(`/form-config/${apiCode}`)
}
// Console专用API调用接口
export const consoleApi = {
// 调用产品API使用JWT认证不需要域名认证
callProductAPI: (apiCode, requestBody, accessId) => {
return request.post(`/console/${apiCode}`, requestBody, {
headers: {
'Access-Id': accessId
}
})
}
}
// 导出所有API
export default {
user: userApi,
certification: certificationApi,
finance: financeApi,
product: productApi,
category: categoryApi,
subscription: subscriptionApi,
productAdmin: productAdminApi,
apiKeys: apiKeysApi,
whiteList: whiteListApi,
api: apiApi,
invoice: invoiceApi,
adminInvoice: adminInvoiceApi
}

98
src/api/invoice.js Normal file
View File

@@ -0,0 +1,98 @@
import request from '@/utils/request'
// 发票API接口
export const invoiceApi = {
// 申请开票
applyInvoice(data) {
return request({
url: '/invoices/apply',
method: 'post',
data
})
},
// 获取用户发票信息
getUserInvoiceInfo() {
return request({
url: '/invoices/info',
method: 'get'
})
},
// 更新用户发票信息
updateUserInvoiceInfo(data) {
return request({
url: '/invoices/info',
method: 'put',
data
})
},
// 获取用户开票记录
getUserInvoiceRecords(params) {
return request({
url: '/invoices/records',
method: 'get',
params
})
},
// 获取可开票金额
getAvailableAmount() {
return request({
url: '/invoices/available-amount',
method: 'get'
})
},
// 下载发票文件
downloadInvoiceFile(applicationId) {
return request({
url: `/invoices/${applicationId}/download`,
method: 'get',
responseType: 'blob'
})
}
}
// 管理员发票API接口
export const adminInvoiceApi = {
// 获取待处理的发票申请列表
getPendingApplications(params) {
return request({
url: '/admin/invoices/pending',
method: 'get',
params
})
},
// 通过发票申请(上传发票)
approveInvoiceApplication(applicationId, formData) {
return request({
url: `/admin/invoices/${applicationId}/approve`,
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
},
// 拒绝发票申请
rejectInvoiceApplication(applicationId, data) {
return request({
url: `/admin/invoices/${applicationId}/reject`,
method: 'post',
data
})
},
// 下载发票文件(管理员)
downloadInvoiceFile(applicationId) {
return request({
url: `/admin/invoices/${applicationId}/download`,
method: 'get',
responseType: 'blob'
})
}
}

661
src/api/statistics/index.js Normal file
View File

@@ -0,0 +1,661 @@
import request from '@/utils/request'
// ================ 用户端统计接口 ================
/**
* 获取公开统计信息
* @returns {Promise}
*/
export function getPublicStatistics() {
return request({
url: '/statistics/public',
method: 'get'
})
}
/**
* 获取用户统计信息
* @returns {Promise}
*/
export function getUserStatistics() {
return request({
url: '/statistics/user',
method: 'get'
})
}
// ================ 独立统计接口 ================
/**
* 获取API调用统计
* @param {Object} params - 查询参数 {user_id?, start_date, end_date, unit}
* @returns {Promise}
*/
export function getApiCallsStatistics(params) {
return request({
url: '/statistics/api-calls',
method: 'get',
params
})
}
/**
* 获取消费统计
* @param {Object} params - 查询参数 {user_id?, start_date, end_date, unit}
* @returns {Promise}
*/
export function getConsumptionStatistics(params) {
return request({
url: '/statistics/consumption',
method: 'get',
params
})
}
/**
* 获取充值统计
* @param {Object} params - 查询参数 {user_id?, start_date, end_date, unit}
* @returns {Promise}
*/
export function getRechargeStatistics(params) {
return request({
url: '/statistics/recharge',
method: 'get',
params
})
}
/**
* 获取最新产品推荐
* @param {Object} params - 查询参数 {limit?}
* @returns {Promise}
*/
export function getLatestProducts(params = {}) {
return request({
url: '/statistics/latest-products',
method: 'get',
params
})
}
/**
* 获取指标列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getMetrics(params) {
return request({
url: '/statistics/metrics',
method: 'get',
params
})
}
/**
* 获取单个指标
* @param {string} id - 指标ID
* @returns {Promise}
*/
export function getMetric(id) {
return request({
url: `/statistics/metrics/${id}`,
method: 'get'
})
}
/**
* 获取报告列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getReports(params) {
return request({
url: '/statistics/reports',
method: 'get',
params
})
}
/**
* 获取单个报告
* @param {string} id - 报告ID
* @returns {Promise}
*/
export function getReport(id) {
return request({
url: `/statistics/reports/${id}`,
method: 'get'
})
}
/**
* 获取仪表板列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getDashboards(params) {
return request({
url: '/statistics/dashboards',
method: 'get',
params
})
}
/**
* 获取单个仪表板
* @param {string} id - 仪表板ID
* @returns {Promise}
*/
export function getDashboard(id) {
return request({
url: `/statistics/dashboards/${id}`,
method: 'get'
})
}
/**
* 获取仪表板数据
* @param {string} id - 仪表板ID
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getDashboardData(id, params) {
return request({
url: `/statistics/dashboards/${id}/data`,
method: 'get',
params
})
}
// ================ 管理员统计接口 ================
/**
* 管理员获取系统统计
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function adminGetSystemStatistics(params) {
return request({
url: '/admin/statistics/system',
method: 'get',
params
})
}
/**
* 管理员获取单个用户统计
* @param {string} userId - 用户ID
* @returns {Promise}
*/
export function adminGetUserStatistics(userId) {
return request({
url: `/admin/statistics/users/${userId}`,
method: 'get'
})
}
/**
* 管理员获取API调用统计
* @param {Object} params - 查询参数 {start_date, end_date, unit}
* @returns {Promise}
*/
export function adminGetApiCallsStatistics(params) {
return request({
url: '/admin/statistics/api-calls',
method: 'get',
params
})
}
/**
* 管理员获取消费统计
* @param {Object} params - 查询参数 {start_date, end_date, unit}
* @returns {Promise}
*/
export function adminGetConsumptionStatistics(params) {
return request({
url: '/admin/statistics/consumption',
method: 'get',
params
})
}
/**
* 管理员获取充值统计
* @param {Object} params - 查询参数 {start_date, end_date, unit}
* @returns {Promise}
*/
export function adminGetRechargeStatistics(params) {
return request({
url: '/admin/statistics/recharge',
method: 'get',
params
})
}
// ================ 管理员独立域统计接口 ================
/**
* 管理员获取用户域统计
* @param {Object} params - 查询参数 {period, start_date, end_date}
* @returns {Promise}
*/
export function adminGetUserDomainStatistics(params) {
return request({
url: '/admin/statistics/user-domain',
method: 'get',
params
})
}
/**
* 管理员获取API域统计
* @param {Object} params - 查询参数 {period, start_date, end_date}
* @returns {Promise}
*/
export function adminGetApiDomainStatistics(params) {
return request({
url: '/admin/statistics/api-domain',
method: 'get',
params
})
}
/**
* 管理员获取消费域统计
* @param {Object} params - 查询参数 {period, start_date, end_date}
* @returns {Promise}
*/
export function adminGetConsumptionDomainStatistics(params) {
return request({
url: '/admin/statistics/consumption-domain',
method: 'get',
params
})
}
/**
* 管理员获取充值域统计
* @param {Object} params - 查询参数 {period, start_date, end_date}
* @returns {Promise}
*/
export function adminGetRechargeDomainStatistics(params) {
return request({
url: '/admin/statistics/recharge-domain',
method: 'get',
params
})
}
/**
* 管理员触发数据聚合
* @param {Object} data - 聚合参数
* @returns {Promise}
*/
export function adminTriggerAggregation(data) {
return request({
url: '/admin/statistics/aggregation/trigger',
method: 'post',
data
})
}
/**
* 管理员创建指标
* @param {Object} data - 指标数据
* @returns {Promise}
*/
export function adminCreateMetric(data) {
return request({
url: '/admin/statistics/metrics',
method: 'post',
data
})
}
/**
* 管理员更新指标
* @param {string} id - 指标ID
* @param {Object} data - 指标数据
* @returns {Promise}
*/
export function adminUpdateMetric(id, data) {
return request({
url: `/admin/statistics/metrics/${id}`,
method: 'put',
data
})
}
/**
* 管理员删除指标
* @param {string} id - 指标ID
* @returns {Promise}
*/
export function adminDeleteMetric(id) {
return request({
url: `/admin/statistics/metrics/${id}`,
method: 'delete'
})
}
/**
* 管理员获取指标列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function adminGetMetrics(params) {
return request({
url: '/admin/statistics/metrics',
method: 'get',
params
})
}
/**
* 管理员获取单个指标
* @param {string} id - 指标ID
* @returns {Promise}
*/
export function adminGetMetric(id) {
return request({
url: `/admin/statistics/metrics/${id}`,
method: 'get'
})
}
/**
* 管理员获取报告列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function adminGetReports(params) {
return request({
url: '/admin/statistics/reports',
method: 'get',
params
})
}
/**
* 管理员获取单个报告
* @param {string} id - 报告ID
* @returns {Promise}
*/
export function adminGetReport(id) {
return request({
url: `/admin/statistics/reports/${id}`,
method: 'get'
})
}
/**
* 管理员删除报告
* @param {string} id - 报告ID
* @returns {Promise}
*/
export function adminDeleteReport(id) {
return request({
url: `/admin/statistics/reports/${id}`,
method: 'delete'
})
}
/**
* 管理员创建仪表板
* @param {Object} data - 仪表板数据
* @returns {Promise}
*/
export function adminCreateDashboard(data) {
return request({
url: '/admin/statistics/dashboards',
method: 'post',
data
})
}
/**
* 管理员更新仪表板
* @param {string} id - 仪表板ID
* @param {Object} data - 仪表板数据
* @returns {Promise}
*/
export function adminUpdateDashboard(id, data) {
return request({
url: `/admin/statistics/dashboards/${id}`,
method: 'put',
data
})
}
/**
* 管理员删除仪表板
* @param {string} id - 仪表板ID
* @returns {Promise}
*/
export function adminDeleteDashboard(id) {
return request({
url: `/admin/statistics/dashboards/${id}`,
method: 'delete'
})
}
/**
* 管理员获取仪表板列表
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function adminGetDashboards(params) {
return request({
url: '/admin/statistics/dashboards',
method: 'get',
params
})
}
/**
* 管理员获取单个仪表板
* @param {string} id - 仪表板ID
* @returns {Promise}
*/
export function adminGetDashboard(id) {
return request({
url: `/admin/statistics/dashboards/${id}`,
method: 'get'
})
}
/**
* 管理员获取仪表板数据
* @param {string} id - 仪表板ID
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function adminGetDashboardData(id, params) {
return request({
url: `/admin/statistics/dashboards/${id}/data`,
method: 'get',
params
})
}
// ================ 便捷方法 ================
/**
* 获取API调用统计
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getApiCallStats(params = {}) {
return getMetrics({
metric_type: 'api_calls',
...params
})
}
/**
* 获取用户统计
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getUserStats(params = {}) {
return getMetrics({
metric_type: 'users',
...params
})
}
/**
* 获取财务统计
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getFinanceStats(params = {}) {
return getMetrics({
metric_type: 'finance',
...params
})
}
/**
* 获取产品统计
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getProductStats(params = {}) {
return getMetrics({
metric_type: 'products',
...params
})
}
/**
* 获取认证统计
* @param {Object} params - 查询参数
* @returns {Promise}
*/
export function getCertificationStats(params = {}) {
return getMetrics({
metric_type: 'certification',
...params
})
}
/**
* 获取今日仪表板数据
* @param {string} dashboardId - 仪表板ID
* @param {string} userRole - 用户角色
* @returns {Promise}
*/
export function getTodayDashboardData(dashboardId, userRole = 'user') {
return getDashboardData(dashboardId, {
user_role: userRole,
period: 'today'
})
}
/**
* 获取本周仪表板数据
* @param {string} dashboardId - 仪表板ID
* @param {string} userRole - 用户角色
* @returns {Promise}
*/
export function getWeekDashboardData(dashboardId, userRole = 'user') {
return getDashboardData(dashboardId, {
user_role: userRole,
period: 'week'
})
}
/**
* 获取本月仪表板数据
* @param {string} dashboardId - 仪表板ID
* @param {string} userRole - 用户角色
* @returns {Promise}
*/
export function getMonthDashboardData(dashboardId, userRole = 'user') {
return getDashboardData(dashboardId, {
user_role: userRole,
period: 'month'
})
}
/**
* 获取自定义时间范围仪表板数据
* @param {string} dashboardId - 仪表板ID
* @param {string} userRole - 用户角色
* @param {string} startDate - 开始日期
* @param {string} endDate - 结束日期
* @returns {Promise}
*/
export function getCustomDashboardData(dashboardId, userRole, startDate, endDate) {
return getDashboardData(dashboardId, {
user_role: userRole,
start_date: startDate,
end_date: endDate
})
}
// ================ 管理员排行榜接口 ================
/**
* 获取用户调用排行榜
* @param {Object} params - 查询参数
* @param {string} params.type - 排行类型 (calls, consumption)
* @param {string} params.period - 时间周期 (today, month, total)
* @param {number} params.limit - 返回数量
* @returns {Promise}
*/
export function adminGetUserCallRanking(params = {}) {
return request({
url: '/admin/statistics/user-call-ranking',
method: 'get',
params
})
}
/**
* 获取充值排行榜
* @param {Object} params - 查询参数
* @param {string} params.period - 时间周期 (today, month, total)
* @param {number} params.limit - 返回数量
* @returns {Promise}
*/
export function adminGetRechargeRanking(params = {}) {
return request({
url: '/admin/statistics/recharge-ranking',
method: 'get',
params
})
}
/**
* 获取API受欢迎程度排行榜
* @param {Object} params - 查询参数
* @param {string} params.period - 时间周期 (today, month, total)
* @param {number} params.limit - 返回数量
* @returns {Promise}
*/
export function adminGetApiPopularityRanking(params = {}) {
return request({
url: '/admin/statistics/api-popularity-ranking',
method: 'get',
params
})
}
/**
* 获取今日认证企业列表
* @param {Object} params - 查询参数
* @param {number} params.limit - 返回数量
* @returns {Promise}
*/
export function adminGetTodayCertifiedEnterprises(params = {}) {
return request({
url: '/admin/statistics/today-certified-enterprises',
method: 'get',
params
})
}

38
src/assets/base.css Normal file
View File

@@ -0,0 +1,38 @@
*,
*::before,
*::after {
box-sizing: border-box;
font-weight: normal;
}
:root {
/* --color-background: #f5f7fa; */
--color-text: #222;
}
body {
min-height: 100vh;
color: var(--color-text);
/* background: var(--color-background); */
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

1
src/assets/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

2
src/assets/main.css Normal file
View File

@@ -0,0 +1,2 @@
@import './base.css';
@import "tailwindcss";

120
src/assets/styles/auth.css Normal file
View File

@@ -0,0 +1,120 @@
/* 认证页面全局样式 */
/* 输入框样式优化 */
.auth-input :deep(.el-input__wrapper) {
border-radius: 8px !important;
transition: all 0.3s ease !important;
border: 1px solid #d1d5db !important;
}
.auth-input :deep(.el-input__wrapper:hover) {
border-color: #3b82f6 !important;
}
.auth-input :deep(.el-input__wrapper.is-focus) {
border-color: #3b82f6 !important;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1) !important;
}
/* 按钮样式优化 */
.auth-button :deep(.el-button--primary) {
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%) !important;
border: none !important;
}
.auth-button :deep(.el-button--primary:hover) {
transform: translateY(-1px) !important;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3) !important;
background: linear-gradient(135deg, #2563eb 0%, #1e40af 100%) !important;
}
.auth-button :deep(.el-button--primary:active) {
transform: translateY(0) !important;
}
/* Radio button 样式优化 */
.auth-radio :deep(.el-radio-button__inner) {
border: none !important;
background: transparent !important;
color: #6b7280 !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
padding: 12px 16px !important;
}
.auth-radio :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: white !important;
color: #3b82f6 !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06) !important;
}
/* 表单标签样式 */
.auth-label {
@apply block text-sm font-medium text-gray-700 mb-2;
}
/* 链接样式 */
.auth-link {
@apply text-gray-600 hover:text-sky-600 transition-colors mx-2;
}
/* 卡片样式 */
.auth-card {
@apply bg-white/95 backdrop-blur-sm shadow-2xl rounded-2xl border border-white/20;
}
/* 标题样式 */
.auth-title {
@apply text-2xl font-bold text-gray-900 mb-2;
}
.auth-subtitle {
@apply text-gray-600 text-sm;
}
/* 响应式优化 */
@media (max-width: 640px) {
.auth-input :deep(.el-input__wrapper) {
border-radius: 6px !important;
}
.auth-button :deep(.el-button--primary) {
border-radius: 6px !important;
}
}
/* 动画效果 */
.auth-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 加载状态样式 */
.auth-loading {
@apply opacity-75 pointer-events-none;
}
/* 错误状态样式 */
.auth-error :deep(.el-input__wrapper) {
border-color: #ef4444 !important;
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.1) !important;
}
/* 成功状态样式 */
.auth-success :deep(.el-input__wrapper) {
border-color: #10b981 !important;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.1) !important;
}

460
src/assets/styles/index.css Normal file
View File

@@ -0,0 +1,460 @@
@import "tailwindcss";
@import "./performance.css";
@import "./auth.css";
@import "./list-pages.css";
/* 全局样式重置 */
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* background: #f8fafc; */
}
/* Element Plus 样式覆盖 */
.el-button {
font-weight: 500;
}
.el-button--primary {
background-color: #3b82f6;
border-color: #3b82f6;
}
.el-button--primary:hover {
background-color: #2563eb;
border-color: #2563eb;
}
.el-button--primary:focus {
background-color: #1d4ed8;
border-color: #1d4ed8;
}
.el-input__inner {
border-radius: 0.375rem;
}
.el-input__inner:focus {
border-color: #3b82f6;
}
.el-form-item__label {
font-weight: 500;
color: #374151;
}
.el-message {
border-radius: 0.5rem;
}
.el-message--success {
background-color: #f0fdf4;
border-color: #bbf7d0;
}
.el-message--error {
background-color: #fef2f2;
border-color: #fecaca;
}
.el-message--warning {
background-color: #fffbeb;
border-color: #fed7aa;
}
.el-message--info {
background-color: #eff6ff;
border-color: #bfdbfe;
}
.el-dropdown-menu {
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.el-dropdown-menu__item {
padding: 0.5rem 1rem;
}
.el-dropdown-menu__item:hover {
background-color: #f3f4f6;
}
.el-avatar {
background-color: #3b82f6;
color: white;
font-weight: 500;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 6px;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 6px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Firefox 滚动条样式 */
* {
scrollbar-width: auto;
scrollbar-color: #cbd5e1 #f1f5f9;
}
/* 响应式设计 */
@media (max-width: 768px) {
.el-dropdown-menu {
min-width: 120px;
}
.el-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
}
/* 动画效果 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from {
transform: translateX(-100%);
}
.slide-leave-to {
transform: translateX(100%);
}
/* 加载动画 */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #f3f4f6;
border-radius: 50%;
border-top-color: #3b82f6;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 工具类 */
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-ellipsis-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.text-ellipsis-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 阴影效果 */
.shadow-soft {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.shadow-medium {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.shadow-large {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* 边框效果 */
.border-soft {
border: 1px solid #e5e7eb;
}
.border-medium {
border: 1px solid #d1d5db;
}
/* 圆角效果 */
.rounded-soft {
border-radius: 0.375rem;
}
.rounded-medium {
border-radius: 0.5rem;
}
.rounded-large {
border-radius: 0.75rem;
}
/* 间距工具类 */
.space-y-soft > * + * {
margin-top: 0.5rem;
}
.space-y-medium > * + * {
margin-top: 1rem;
}
.space-y-large > * + * {
margin-top: 1.5rem;
}
.space-x-soft > * + * {
margin-left: 0.5rem;
}
.space-x-medium > * + * {
margin-left: 1rem;
}
.space-x-large > * + * {
margin-left: 1.5rem;
}
/* 状态颜色 */
.status-success {
color: #059669;
background-color: #d1fae5;
}
.status-error {
color: #dc2626;
background-color: #fee2e2;
}
.status-warning {
color: #d97706;
background-color: #fef3c7;
}
.status-info {
color: #2563eb;
background-color: #dbeafe;
}
/* 卡片样式 */
.card {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
padding: 1.5rem;
}
.card-header {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
margin-bottom: 1rem;
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin: 0;
}
.card-subtitle {
font-size: 0.875rem;
color: #6b7280;
margin: 0.25rem 0 0 0;
}
/* 表格样式 */
.table-container {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.table-header {
background-color: #f9fafb;
border-bottom: 1px solid #e5e7eb;
padding: 1rem 1.5rem;
}
.table-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin: 0;
}
/* 表单样式 */
.form-section {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.form-section-title {
font-size: 1.125rem;
font-weight: 600;
color: #111827;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
/* 按钮组样式 */
.button-group {
display: flex;
gap: 0.5rem;
align-items: center;
}
.button-group-vertical {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* 徽章样式 */
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.badge-success {
background-color: #d1fae5;
color: #059669;
}
.badge-error {
background-color: #fee2e2;
color: #dc2626;
}
.badge-warning {
background-color: #fef3c7;
color: #d97706;
}
.badge-info {
background-color: #dbeafe;
color: #2563eb;
}
.badge-gray {
background-color: #f3f4f6;
color: #6b7280;
}
/* 空状态样式 */
.empty-state {
text-align: center;
padding: 3rem 1.5rem;
}
.empty-state-icon {
width: 4rem;
height: 4rem;
margin: 0 auto 1rem;
color: #9ca3af;
}
.empty-state-title {
font-size: 1.125rem;
font-weight: 500;
color: #374151;
margin: 0 0 0.5rem 0;
}
.empty-state-description {
font-size: 0.875rem;
color: #6b7280;
margin: 0;
}
/* 响应式工具类 */
@media (max-width: 640px) {
.sm\:hidden {
display: none;
}
.sm\:block {
display: block;
}
.sm\:flex {
display: flex;
}
}
@media (max-width: 768px) {
.md\:hidden {
display: none;
}
.md\:block {
display: block;
}
.md\:flex {
display: flex;
}
}
@media (max-width: 1024px) {
.lg\:hidden {
display: none;
}
.lg\:block {
display: block;
}
.lg\:flex {
display: flex;
}
}

View File

@@ -0,0 +1,445 @@
/* 列表页通用样式 - 科技感、简约、高级设计 */
/* ===== 页面容器 ===== */
.list-page-container {
min-height: 100vh;
position: relative;
}
/* ===== 主卡片容器 ===== */
.list-page-card {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 16px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.05),
0 10px 15px -3px rgba(0, 0, 0, 0.03),
0 0 0 1px rgba(255, 255, 255, 0.1);
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
padding: 24px;
}
.list-page-card:hover {
box-shadow:
0 10px 25px -3px rgba(0, 0, 0, 0.08),
0 4px 6px -2px rgba(0, 0, 0, 0.04),
0 0 0 1px rgba(255, 255, 255, 0.15);
}
/* ===== 页面头部 ===== */
.list-page-header {
padding: 24px 24px 18px;
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
background: linear-gradient(135deg, rgba(248, 250, 252, 0.8) 0%, rgba(241, 245, 249, 0.8) 100%);
}
.list-page-title {
font-size: 20px;
font-weight: 700;
color: #1e293b;
margin: 0 0 8px 0;
letter-spacing: -0.025em;
line-height: 1.2;
}
.list-page-subtitle {
font-size: 14px;
color: #64748b;
margin: 0;
font-weight: 400;
line-height: 1.5;
}
.list-page-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* ===== 筛选区域 ===== */
.list-page-filters {
padding: 24px 32px;
background: rgba(248, 250, 252, 0.5);
border-bottom: 1px solid rgba(226, 232, 240, 0.4);
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.filter-item {
display: flex;
flex-direction: column;
}
.filter-label {
font-size: 14px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
letter-spacing: 0.025em;
}
.filter-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 20px;
border-top: 1px solid rgba(226, 232, 240, 0.4);
}
.filter-stats {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.filter-buttons {
display: flex;
gap: 12px;
}
/* ===== 表格区域 ===== */
.list-page-table {
/* padding: 32px; */
}
.table-wrapper {
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.6);
background: rgba(255, 255, 255, 0.8);
}
/* ===== 分页区域 ===== */
.list-page-pagination {
padding: 24px 32px;
border-top: 1px solid rgba(226, 232, 240, 0.4);
background: rgba(248, 250, 252, 0.3);
display: flex;
justify-content: center;
}
/* ===== Element Plus 组件样式覆盖 ===== */
/* 按钮样式 */
.list-page-container .el-button {
border-radius: 8px;
font-weight: 600;
letter-spacing: 0.025em;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
.list-page-container .el-button--primary {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-color: #3b82f6;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.list-page-container .el-button--primary:hover {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
border-color: #2563eb;
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
transform: translateY(-1px);
}
.list-page-container .el-button--default {
background: rgba(255, 255, 255, 0.9);
border-color: rgba(226, 232, 240, 0.8);
color: #475569;
}
.list-page-container .el-button--default:hover {
background: rgba(255, 255, 255, 1);
border-color: #cbd5e1;
color: #1e293b;
transform: translateY(-1px);
}
.list-page-container .el-button--danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: #ef4444;
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
}
.list-page-container .el-button--danger:hover {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
border-color: #dc2626;
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
transform: translateY(-1px);
}
/* 输入框样式 */
.list-page-container .el-input__wrapper {
border-radius: 8px;
border: 1px solid rgba(226, 232, 240, 0.8);
background: rgba(255, 255, 255, 0.9);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.list-page-container .el-input__wrapper:hover {
border-color: #cbd5e1;
background: rgba(255, 255, 255, 1);
}
.list-page-container .el-input__wrapper.is-focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
background: rgba(255, 255, 255, 1);
}
.list-page-container .el-input__inner {
color: #1e293b;
font-weight: 500;
}
.list-page-container .el-input__inner::placeholder {
color: #94a3b8;
font-weight: 400;
}
/* 选择器样式 */
.list-page-container .el-select .el-input__wrapper {
border-radius: 8px;
}
.list-page-container .el-select-dropdown {
border-radius: 12px;
border: 1px solid rgba(226, 232, 240, 0.8);
box-shadow:
0 10px 25px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.95);
}
.list-page-container .el-select-dropdown__item {
color: #475569;
font-weight: 500;
transition: all 0.2s ease;
}
.list-page-container .el-select-dropdown__item:hover {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.list-page-container .el-select-dropdown__item.selected {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
font-weight: 600;
}
/* 表格样式 */
.list-page-container .el-table {
background: transparent;
border: none;
}
.list-page-container .el-table th {
background: rgba(248, 250, 252, 0.8);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
color: #475569;
font-weight: 600;
font-size: 14px;
letter-spacing: 0.025em;
padding: 16px 12px;
}
.list-page-container .el-table td {
border-bottom: 1px solid rgba(226, 232, 240, 0.3);
/* padding: 16px 12px; */
color: #1e293b;
font-weight: 500;
}
.list-page-container .el-table tr:hover > td {
background: rgba(59, 130, 246, 0.02);
}
.list-page-container .el-table--striped .el-table__body tr.el-table__row--striped td {
background: rgba(248, 250, 252, 0.3);
}
.list-page-container .el-table--striped .el-table__body tr.el-table__row--striped:hover td {
background: rgba(59, 130, 246, 0.04);
}
/* 标签样式 */
.list-page-container .el-tag {
border-radius: 6px;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.025em;
border: none;
padding: 4px 8px;
}
.list-page-container .el-tag--success {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.list-page-container .el-tag--danger {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.list-page-container .el-tag--warning {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
}
.list-page-container .el-tag--info {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
/* 分页样式 */
.list-page-container .el-pagination {
--el-pagination-bg-color: transparent;
--el-pagination-text-color: #475569;
--el-pagination-border-radius: 8px;
--el-pagination-button-color: #64748b;
--el-pagination-button-bg-color: rgba(255, 255, 255, 0.8);
--el-pagination-button-disabled-color: #cbd5e1;
--el-pagination-button-disabled-bg-color: rgba(248, 250, 252, 0.5);
--el-pagination-hover-color: #3b82f6;
--el-pagination-hover-bg-color: rgba(59, 130, 246, 0.1);
}
.list-page-container .el-pagination .el-pager li {
border-radius: 8px;
font-weight: 600;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-page-container .el-pagination .el-pager li:hover {
transform: translateY(-1px);
}
.list-page-container .el-pagination .el-pager li.is-active {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
/* 卡片样式 */
.list-page-container .el-card {
border-radius: 12px;
border: 1px solid rgba(226, 232, 240, 0.6);
background: rgba(255, 255, 255, 0.9);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.05),
0 1px 2px rgba(0, 0, 0, 0.03);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-page-container .el-card:hover {
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.08),
0 2px 4px rgba(0, 0, 0, 0.05);
transform: translateY(-1px);
}
.list-page-container .el-card__body {
padding: 20px;
}
/* 加载状态 */
.list-page-container .el-loading-mask {
background: rgba(255, 255, 255, 0.95);
}
/* 响应式设计 */
@media (max-width: 768px) {
.list-page-container {
padding: 16px;
}
.list-page-header {
padding: 24px 20px 20px;
}
.list-page-title {
font-size: 24px;
}
.list-page-filters {
padding: 20px;
}
.filter-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.list-page-table {
padding: 0 20px 20px;
}
.list-page-pagination {
padding: 20px;
}
.filter-actions {
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.filter-buttons {
justify-content: center;
}
}
/* 动画效果 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.list-page-card {
animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 性能优化:减少动画效果 */
@media (prefers-reduced-motion: reduce) {
.list-page-card {
animation: none !important;
}
}
/* 科技感装饰元素 */
.list-page-container::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent 0%, rgba(59, 130, 246, 0.3) 50%, transparent 100%);
z-index: 1;
}
.list-page-container::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 100vh;
background: linear-gradient(180deg, transparent 0%, rgba(59, 130, 246, 0.1) 50%, transparent 100%);
z-index: 1;
}

View File

@@ -0,0 +1,186 @@
/* 渲染性能优化样式文件 */
/* 1. 减少动画效果 - 尊重用户偏好 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 2. 低端设备优化 */
@media (max-width: 768px), (max-device-pixel-ratio: 1) {
/* 简化背景渐变 */
.bg-gradient-to-br,
.bg-gradient-to-tl,
.bg-gradient-to-tr,
.bg-gradient-to-bl {
background: #f8fafc !important;
}
/* 简化阴影效果 */
.shadow-soft,
.shadow-medium,
.shadow-large {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) !important;
}
/* 移除复杂的边框效果 */
.border-soft,
.border-medium {
border: 1px solid #e5e7eb !important;
}
}
/* 3. 硬件加速优化 */
.gpu-accelerated {
transform: translateZ(0);
will-change: transform;
}
/* 4. 简化backdrop-filter的替代方案 */
.glass-effect {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(226, 232, 240, 0.6);
}
.glass-effect-light {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(226, 232, 240, 0.4);
}
/* 5. 优化过渡效果 */
.optimized-transition {
transition: all 0.2s ease;
will-change: transform, opacity;
}
/* 6. 移动端性能优化 */
@media (max-width: 640px) {
/* 减少复杂的CSS计算 */
.mobile-optimized {
transform: none !important;
filter: none !important;
}
/* 简化字体渲染 */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* 7. 高DPI屏幕优化 */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
/* 为高分辨率屏幕优化 */
.high-dpi-optimized {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
}
/* 8. 减少重绘和重排 */
.layout-stable {
contain: layout style paint;
}
/* 9. 优化滚动性能 */
.smooth-scroll {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
/* 10. 减少不必要的动画 */
@media (max-width: 1024px) {
.desktop-only-animation {
animation: none !important;
}
}
/* 11. 优化图片和媒体 */
img, video, canvas {
max-width: 100%;
height: auto;
}
/* 12. 减少CSS选择器复杂度 */
.simple-selector {
/* 使用简单的选择器 */
}
/* 13. 优化字体加载 */
.font-optimized {
font-display: swap;
}
/* 14. 减少阴影复杂度 */
.simple-shadow {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 15. 优化边框半径 */
.optimized-radius {
border-radius: 8px;
}
/* 16. 减少透明度计算 */
.solid-bg {
background: #ffffff;
}
/* 17. 优化渐变效果 */
.simple-gradient {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
}
/* 18. 减少变换复杂度 */
.simple-transform {
transform: translateY(-2px);
}
/* 19. 优化伪元素 */
.optimized-pseudo::before,
.optimized-pseudo::after {
content: '';
position: absolute;
pointer-events: none;
}
/* 20. 减少媒体查询嵌套 */
@media (max-width: 768px) {
.mobile-simple {
/* 简化的移动端样式 */
}
}
/* 21. 性能等级模式样式 */
.low-performance-mode {
/* 低端设备:禁用复杂效果 */
}
.medium-performance-mode {
/* 中端设备:适度优化 */
}
.high-performance-mode {
/* 高端设备:启用所有功能 */
}
/* 22. 硬件加速降级样式 */
.no-hardware-acceleration {
/* 不支持硬件加速时的降级样式 */
}
/* 23. backdrop-filter降级样式 */
.no-backdrop-filter {
/* 不支持backdrop-filter时的降级样式 */
}
/* 24. 减少动画模式样式 */
.reduced-motion {
/* 用户偏好减少动画时的样式 */
}

View File

@@ -0,0 +1,44 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,94 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

View File

@@ -0,0 +1,86 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

View File

@@ -0,0 +1,501 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑API配置' : '配置API'"
width="80%"
:close-on-click-modal="false"
@close="handleClose"
>
<div v-loading="loading" class="api-config-form">
<!-- 基本信息 -->
<div class="form-section">
<h3 class="section-title">基本信息</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="form-item">
<label class="form-label">产品名称</label>
<el-input v-model="product.name" readonly />
</div>
<div class="form-item">
<label class="form-label">产品编号</label>
<el-input v-model="product.code" readonly />
</div>
</div>
</div>
<!-- 请求参数配置 -->
<div class="form-section">
<div class="section-header">
<h3 class="section-title">请求参数配置</h3>
<el-button type="primary" size="small" @click="addRequestParam">
添加参数
</el-button>
</div>
<div v-if="form.request_params.length === 0" class="empty-state">
<el-empty description="暂无请求参数,请添加参数" />
</div>
<div v-else class="params-list">
<div
v-for="(param, index) in form.request_params"
:key="index"
class="param-item"
>
<div class="param-header">
<span class="param-title">参数 {{ index + 1 }}</span>
<el-button
type="danger"
size="small"
@click="removeRequestParam(index)"
>
删除
</el-button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="form-item">
<label class="form-label">参数名称 <span class="text-red-500">*</span></label>
<el-input v-model="param.name" placeholder="如:姓名" />
</div>
<div class="form-item">
<label class="form-label">字段名 <span class="text-red-500">*</span></label>
<el-input v-model="param.field" placeholder="如name" />
</div>
<div class="form-item">
<label class="form-label">参数类型</label>
<el-select v-model="param.type" placeholder="选择类型" class="w-full">
<el-option label="文本" value="text" />
<el-option label="数字" value="number" />
<el-option label="密码" value="password" />
<el-option label="邮箱" value="email" />
<el-option label="手机号" value="phone" />
<el-option label="身份证" value="idcard" />
</el-select>
</div>
<div class="form-item">
<label class="form-label">是否必填</label>
<el-switch v-model="param.required" />
</div>
<div class="form-item">
<label class="form-label">参数示例</label>
<el-input v-model="param.example" placeholder="如:张三" />
</div>
<div class="form-item">
<label class="form-label">验证规则</label>
<el-input v-model="param.validation" placeholder="如:^[\\u4e00-\\u9fa5]{2,4}$" />
</div>
</div>
<div class="form-item">
<label class="form-label">参数描述</label>
<el-input
v-model="param.description"
type="textarea"
:rows="2"
placeholder="请输入参数描述"
/>
</div>
</div>
</div>
</div>
<!-- 响应字段配置 -->
<div class="form-section">
<div class="section-header">
<h3 class="section-title">响应字段配置</h3>
<el-button type="primary" size="small" @click="addResponseField">
添加字段
</el-button>
</div>
<div v-if="form.response_fields.length === 0" class="empty-state">
<el-empty description="暂无响应字段,请添加字段" />
</div>
<div v-else class="fields-list">
<div
v-for="(field, index) in form.response_fields"
:key="index"
class="field-item"
>
<div class="field-header">
<span class="field-title">字段 {{ index + 1 }}</span>
<el-button
type="danger"
size="small"
@click="removeResponseField(index)"
>
删除
</el-button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="form-item">
<label class="form-label">字段名称 <span class="text-red-500">*</span></label>
<el-input v-model="field.name" placeholder="如:姓名" />
</div>
<div class="form-item">
<label class="form-label">字段路径 <span class="text-red-500">*</span></label>
<el-input v-model="field.path" placeholder="如data.name" />
</div>
<div class="form-item">
<label class="form-label">字段类型</label>
<el-select v-model="field.type" placeholder="选择类型" class="w-full">
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔值" value="boolean" />
<el-option label="对象" value="object" />
<el-option label="数组" value="array" />
</el-select>
</div>
<div class="form-item">
<label class="form-label">是否必填</label>
<el-switch v-model="field.required" />
</div>
<div class="form-item">
<label class="form-label">字段示例</label>
<el-input v-model="field.example" placeholder="如:张三" />
</div>
</div>
<div class="form-item">
<label class="form-label">字段描述</label>
<el-input
v-model="field.description"
type="textarea"
:rows="2"
placeholder="请输入字段描述"
/>
</div>
</div>
</div>
</div>
<!-- 响应示例 -->
<div class="form-section">
<h3 class="section-title">响应示例</h3>
<div class="form-item">
<label class="form-label">JSON响应示例 <span class="text-red-500">*</span></label>
<el-input
v-model="form.response_example_text"
type="textarea"
:rows="8"
placeholder="请输入JSON格式的响应示例"
@input="handleResponseExampleChange"
/>
<div class="form-tip">请确保输入的是有效的JSON格式</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { productAdminApi } from '@/api'
import { ElMessage } from 'element-plus'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
product: {
type: Object,
required: true
}
})
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const loading = ref(false)
const submitting = ref(false)
const isEdit = ref(false)
// 表单数据
const form = reactive({
request_params: [],
response_fields: [],
response_example_text: '',
response_example: {}
})
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 监听产品变化
watch(() => props.product, async (newProduct) => {
if (newProduct && newProduct.id) {
await loadApiConfig()
}
}, { immediate: true })
// 加载API配置
const loadApiConfig = async () => {
if (!props.product?.id) return
loading.value = true
try {
const response = await productAdminApi.getProductApiConfig(props.product.id)
if (response.success && response.data) {
// 检查是否有配置ID如果有则为编辑模式否则为新建模式
if (response.data.id) {
// 编辑模式
isEdit.value = true
form.request_params = response.data.request_params || []
form.response_fields = response.data.response_fields || []
form.response_example = response.data.response_example || {}
form.response_example_text = JSON.stringify(response.data.response_example, null, 2)
} else {
// 新建模式(返回了空配置)
isEdit.value = false
resetForm()
}
} else {
// 新建模式
isEdit.value = false
resetForm()
}
} catch (error) {
// 如果配置不存在,则为新建模式
isEdit.value = false
resetForm()
} finally {
loading.value = false
}
}
// 重置表单
const resetForm = () => {
form.request_params = []
form.response_fields = []
form.response_example_text = ''
form.response_example = {}
}
// 添加请求参数
const addRequestParam = () => {
form.request_params.push({
name: '',
field: '',
type: 'text',
required: false,
description: '',
example: '',
validation: ''
})
}
// 删除请求参数
const removeRequestParam = (index) => {
form.request_params.splice(index, 1)
}
// 添加响应字段
const addResponseField = () => {
form.response_fields.push({
name: '',
path: '',
type: 'string',
required: false,
description: '',
example: ''
})
}
// 删除响应字段
const removeResponseField = (index) => {
form.response_fields.splice(index, 1)
}
// 处理响应示例变化
const handleResponseExampleChange = () => {
try {
form.response_example = JSON.parse(form.response_example_text)
} catch (error) {
// JSON解析失败时不更新
}
}
// 验证表单
const validateForm = () => {
// 验证请求参数
for (let i = 0; i < form.request_params.length; i++) {
const param = form.request_params[i]
if (!param.name || !param.field) {
ElMessage.error(`请完善第${i + 1}个请求参数的名称和字段名`)
return false
}
}
// 验证响应字段
for (let i = 0; i < form.response_fields.length; i++) {
const field = form.response_fields[i]
if (!field.name || !field.path) {
ElMessage.error(`请完善第${i + 1}个响应字段的名称和路径`)
return false
}
}
// 验证响应示例
if (!form.response_example_text.trim()) {
ElMessage.error('请输入响应示例')
return false
}
try {
JSON.parse(form.response_example_text)
} catch (error) {
ElMessage.error('响应示例必须是有效的JSON格式')
return false
}
return true
}
// 提交表单
const handleSubmit = async () => {
if (!validateForm()) return
submitting.value = true
try {
const submitData = {
request_params: form.request_params,
response_fields: form.response_fields,
response_example: form.response_example
}
if (isEdit.value) {
await productAdminApi.updateProductApiConfig(props.product.id, submitData)
ElMessage.success('API配置更新成功')
} else {
await productAdminApi.createProductApiConfig(props.product.id, submitData)
ElMessage.success('API配置创建成功')
}
emit('success')
handleClose()
} catch (error) {
console.error('保存API配置失败:', error)
ElMessage.error('保存失败,请重试')
} finally {
submitting.value = false
}
}
// 关闭弹窗
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
</script>
<style scoped>
.api-config-form {
max-height: 70vh;
overflow-y: auto;
}
.form-section {
margin-bottom: 2rem;
padding: 1.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
background-color: #fafafa;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
.form-item {
margin-bottom: 1rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.form-tip {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
}
.empty-state {
padding: 2rem;
text-align: center;
}
.param-item,
.field-item {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: white;
}
.param-header,
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.param-title,
.field-title {
font-weight: 600;
color: #374151;
}
.params-list,
.fields-list {
max-height: 400px;
overflow-y: auto;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,671 @@
<template>
<el-dialog
v-model="visible"
:title="getDialogTitle()"
width="95%"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
class="documentation-form"
>
<el-row :gutter="0">
<el-col :span="16">
<el-form-item label="请求链接" prop="request_url">
<el-input v-model="form.request_url" placeholder="请输入API请求链接" clearable />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="请求方法" prop="request_method">
<el-select v-model="form.request_method" placeholder="选择请求方法" class="w-full">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
<el-option label="PATCH" value="PATCH" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<!-- 文档内容编辑区域 -->
<el-form-item label="文档内容" class="documentation-content">
<el-tabs v-model="activeTab" type="card" class="documentation-tabs">
<el-tab-pane label="基础说明" name="basic_info">
<div class="markdown-editor-container">
<div class="editor-header">
<span class="editor-title">基础说明</span>
<div class="editor-actions">
<el-button
type="info"
size="small"
@click="showBasicInfoPreview = !showBasicInfoPreview"
class="preview-btn"
>
{{ showBasicInfoPreview ? '隐藏预览' : '预览默认内容' }}
</el-button>
<el-button
type="primary"
size="small"
@click="fillDefaultBasicInfo"
class="fill-default-btn"
>
填入默认内容
</el-button>
</div>
</div>
<div class="default-content-preview" v-if="showBasicInfoPreview">
<div class="preview-header">
<span>默认内容预览</span>
<el-button type="text" size="small" @click="showBasicInfoPreview = false"
>关闭</el-button
>
</div>
<div
class="preview-content"
v-html="renderMarkdown(DOCUMENTATION_DEFAULTS.basicInfo)"
></div>
</div>
<MarkdownEditor
v-model="form.basic_info"
placeholder="请输入基础说明,包括请求头配置、参数加密要求等..."
:height="400"
/>
</div>
</el-tab-pane>
<el-tab-pane label="请求参数" name="request_params">
<div class="markdown-editor-container">
<div class="editor-header">
<span class="editor-title">请求参数</span>
<div class="editor-actions">
<el-button
type="info"
size="small"
@click="showRequestParamsPreview = !showRequestParamsPreview"
class="preview-btn"
>
{{ showRequestParamsPreview ? '隐藏预览' : '预览默认内容' }}
</el-button>
<el-button
type="primary"
size="small"
@click="fillDefaultRequestParams"
class="fill-default-btn"
>
填入默认内容
</el-button>
</div>
</div>
<div class="default-content-preview" v-if="showRequestParamsPreview">
<div class="preview-header">
<span>默认内容预览</span>
<el-button type="text" size="small" @click="showRequestParamsPreview = false"
>关闭</el-button
>
</div>
<div
class="preview-content"
v-html="renderMarkdown(DOCUMENTATION_DEFAULTS.requestParams)"
></div>
</div>
<MarkdownEditor
v-model="form.request_params"
placeholder="请输入请求参数说明,建议使用表格格式..."
:height="400"
/>
</div>
</el-tab-pane>
<el-tab-pane label="返回字段说明" name="response_fields">
<div class="markdown-editor-container">
<div class="editor-header">
<span class="editor-title">返回字段说明</span>
<div class="editor-actions">
<el-button
type="info"
size="small"
@click="showResponseFieldsPreview = !showResponseFieldsPreview"
class="preview-btn"
>
{{ showResponseFieldsPreview ? '隐藏预览' : '预览默认内容' }}
</el-button>
<el-button
type="primary"
size="small"
@click="fillDefaultResponseFields"
class="fill-default-btn"
>
填入默认内容
</el-button>
</div>
</div>
<div class="default-content-preview" v-if="showResponseFieldsPreview">
<div class="preview-header">
<span>默认内容预览</span>
<el-button type="text" size="small" @click="showResponseFieldsPreview = false"
>关闭</el-button
>
</div>
<div
class="preview-content"
v-html="renderMarkdown(DOCUMENTATION_DEFAULTS.responseFields)"
></div>
</div>
<MarkdownEditor
v-model="form.response_fields"
placeholder="请输入返回字段说明,建议使用表格格式..."
:height="400"
/>
</div>
</el-tab-pane>
<el-tab-pane label="响应示例" name="response_example">
<div class="markdown-editor-container">
<div class="editor-header">
<span class="editor-title">响应示例</span>
<div class="editor-actions">
<el-button
type="info"
size="small"
@click="showResponseExamplePreview = !showResponseExamplePreview"
class="preview-btn"
>
{{ showResponseExamplePreview ? '隐藏预览' : '预览默认内容' }}
</el-button>
<el-button
type="primary"
size="small"
@click="fillDefaultResponseExample"
class="fill-default-btn"
>
填入默认内容
</el-button>
</div>
</div>
<div class="default-content-preview" v-if="showResponseExamplePreview">
<div class="preview-header">
<span>默认内容预览</span>
<el-button type="text" size="small" @click="showResponseExamplePreview = false"
>关闭</el-button
>
</div>
<div
class="preview-content"
v-html="renderMarkdown(DOCUMENTATION_DEFAULTS.responseExample)"
></div>
</div>
<MarkdownEditor
v-model="form.response_example"
placeholder="请输入响应示例,建议使用代码块格式..."
:height="400"
/>
</div>
</el-tab-pane>
<el-tab-pane label="错误代码" name="error_codes">
<div class="markdown-editor-container">
<div class="editor-header">
<span class="editor-title">错误代码</span>
<div class="editor-actions">
<el-button
type="info"
size="small"
@click="showErrorCodesPreview = !showErrorCodesPreview"
class="preview-btn"
>
{{ showErrorCodesPreview ? '隐藏预览' : '预览默认内容' }}
</el-button>
<el-button
type="primary"
size="small"
@click="fillDefaultErrorCodes"
class="fill-default-btn"
>
填入默认内容
</el-button>
</div>
</div>
<div class="default-content-preview" v-if="showErrorCodesPreview">
<div class="preview-header">
<span>默认内容预览</span>
<el-button type="text" size="small" @click="showErrorCodesPreview = false"
>关闭</el-button
>
</div>
<div
class="preview-content"
v-html="renderMarkdown(DOCUMENTATION_DEFAULTS.errorCodes)"
></div>
</div>
<MarkdownEditor
v-model="form.error_codes"
placeholder="请输入错误代码说明,建议使用表格格式..."
:height="400"
/>
</div>
</el-tab-pane>
</el-tabs>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { productAdminApi } from '@/api'
import MarkdownEditor from '@/components/common/MarkdownEditor.vue'
import { DOCUMENTATION_DEFAULTS } from '@/constants/documentationDefaults'
import { ElMessage } from 'element-plus'
import { marked } from 'marked'
import { computed, reactive, ref, watch } from 'vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
product: {
type: Object,
default: null,
},
})
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
})
const loading = ref(false)
const formRef = ref()
const isEdit = ref(false)
const activeTab = ref('basic_info')
// 预览状态
const showBasicInfoPreview = ref(false)
const showRequestParamsPreview = ref(false)
const showResponseFieldsPreview = ref(false)
const showResponseExamplePreview = ref(false)
const showErrorCodesPreview = ref(false)
// 表单数据
const form = reactive({
request_url: '',
request_method: 'POST',
basic_info: '',
request_params: '',
response_fields: '',
response_example: '',
error_codes: '',
})
// 生成默认请求链接
const generateDefaultRequestUrl = () => {
if (!props.product?.code) return ''
const baseUrl = 'https://api.tianyuanapi.com/api/v1/'
const productCode = props.product.code
return `${baseUrl}${productCode}?t=13位时间戳`
}
// 表单验证规则
const rules = {
request_url: [{ required: true, message: '请输入请求链接', trigger: 'blur' }],
request_method: [{ required: true, message: '请选择请求方法', trigger: 'change' }],
}
// 监听产品变化和弹窗状态,加载现有文档
watch(
[() => props.product, () => props.modelValue],
async ([newProduct, isVisible]) => {
if (newProduct && isVisible) {
await loadDocumentation()
}
},
{ immediate: true },
)
// 加载现有文档
const loadDocumentation = async () => {
if (!props.product?.id) return
// 先重置表单,避免数据残留
resetForm()
isEdit.value = false
try {
const response = await productAdminApi.getProductDocumentation(props.product.id)
const doc = response.data
console.log('fetch docs response: ', doc)
if (doc && doc.id) {
// 有文档数据,进入编辑模式
isEdit.value = true
Object.assign(form, {
request_url: doc.request_url || '',
request_method: doc.request_method || 'POST',
basic_info: doc.basic_info || '',
request_params: doc.request_params || '',
response_fields: doc.response_fields || '',
response_example: doc.response_example || '',
error_codes: doc.error_codes || '',
})
} else {
// 没有文档数据,进入创建模式
isEdit.value = false
resetForm()
// 设置默认请求链接
form.request_url = generateDefaultRequestUrl()
}
} catch (error) {
// 如果获取文档失败,进入创建模式
console.log('获取文档失败,进入创建模式:', error.message)
isEdit.value = false
resetForm()
}
}
// 重置表单
const resetForm = () => {
Object.assign(form, {
request_url: generateDefaultRequestUrl(),
request_method: 'POST',
basic_info: '',
request_params: '',
response_fields: '',
response_example: '',
error_codes: '',
})
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
const data = {
request_url: form.request_url,
request_method: form.request_method,
basic_info: form.basic_info,
request_params: form.request_params,
response_fields: form.response_fields,
response_example: form.response_example,
error_codes: form.error_codes,
}
// 无论新建还是编辑都只调用 createProductDocumentation
await productAdminApi.createProductDocumentation(props.product.id, data)
ElMessage.success('文档保存成功')
emit('success')
handleClose()
} catch (error) {
console.error('提交失败:', error)
ElMessage.error(error.message || '操作失败')
} finally {
loading.value = false
}
}
// 获取弹窗标题
const getDialogTitle = () => {
if (!props.product) {
return isEdit.value ? '编辑产品文档' : '配置产品文档'
}
const productName = props.product.name || '未知产品'
const productCode = props.product.code || '未知编号'
const action = isEdit.value ? '编辑' : '配置'
return `${action}产品文档 - ${productName} (${productCode})`
}
// Markdown渲染方法
const renderMarkdown = (content) => {
try {
return marked(content)
} catch (error) {
console.error('Markdown渲染失败:', error)
return content
}
}
// 填充默认内容的方法
const fillDefaultBasicInfo = () => {
form.basic_info = DOCUMENTATION_DEFAULTS.basicInfo
showBasicInfoPreview.value = false
ElMessage.success('已填入基础说明默认内容')
}
const fillDefaultRequestParams = () => {
form.request_params = DOCUMENTATION_DEFAULTS.requestParams
showRequestParamsPreview.value = false
ElMessage.success('已填入请求参数默认内容')
}
const fillDefaultResponseFields = () => {
form.response_fields = DOCUMENTATION_DEFAULTS.responseFields
showResponseFieldsPreview.value = false
ElMessage.success('已填入返回字段说明默认内容')
}
const fillDefaultResponseExample = () => {
form.response_example = DOCUMENTATION_DEFAULTS.responseExample
showResponseExamplePreview.value = false
ElMessage.success('已填入响应示例默认内容')
}
const fillDefaultErrorCodes = () => {
form.error_codes = DOCUMENTATION_DEFAULTS.errorCodes
showErrorCodesPreview.value = false
ElMessage.success('已填入错误代码默认内容')
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
resetForm()
// 重置预览状态
showBasicInfoPreview.value = false
showRequestParamsPreview.value = false
showResponseFieldsPreview.value = false
showResponseExamplePreview.value = false
showErrorCodesPreview.value = false
// 重置编辑状态
isEdit.value = false
if (formRef.value) {
formRef.value.resetFields()
}
}
</script>
<style scoped>
.documentation-form {
max-height: 70vh;
overflow-y: auto;
}
.documentation-content {
margin-top: 20px;
}
.documentation-tabs {
width: 100%;
}
.documentation-tabs :deep(.el-tabs__content) {
padding: 20px 0;
min-height: 450px;
}
.documentation-tabs :deep(.el-tab-pane) {
height: 100%;
}
.dialog-footer {
text-align: right;
}
:deep(.el-form-item__content) {
width: 100%;
}
:deep(.el-dialog__body) {
padding: 20px 30px;
}
:deep(.el-tabs__header) {
margin-bottom: 0;
}
:deep(.el-tabs__nav-wrap) {
padding: 0 20px;
}
/* 新增样式 */
.markdown-editor-container {
position: relative;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding: 0 5px;
}
.editor-title {
font-weight: 500;
color: #606266;
}
.editor-actions {
display: flex;
gap: 8px;
align-items: center;
}
.preview-btn {
font-size: 12px;
padding: 4px 8px;
}
.fill-default-btn {
font-size: 12px;
padding: 4px 8px;
}
.default-content-preview {
margin-bottom: 15px;
border: 1px solid #e4e7ed;
border-radius: 4px;
background-color: #fafafa;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
font-size: 12px;
color: #606266;
}
.preview-content {
padding: 12px;
max-height: 200px;
overflow-y: auto;
font-size: 13px;
line-height: 1.5;
}
.preview-content :deep(h1),
.preview-content :deep(h2),
.preview-content :deep(h3),
.preview-content :deep(h4) {
margin: 8px 0 4px 0;
font-weight: 600;
}
.preview-content :deep(p) {
margin: 4px 0;
}
.preview-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
font-size: 12px;
}
.preview-content :deep(th),
.preview-content :deep(td) {
border: 1px solid #dcdfe6;
padding: 6px 8px;
text-align: left;
}
.preview-content :deep(th) {
background-color: #f5f7fa;
font-weight: 500;
}
.preview-content :deep(code) {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.preview-content :deep(pre) {
background-color: #f6f8fa;
border: 1px solid #e1e4e8;
border-radius: 3px;
padding: 12px;
overflow-x: auto;
margin: 8px 0;
}
.preview-content :deep(pre code) {
background-color: transparent;
padding: 0;
}
.preview-content :deep(ul),
.preview-content :deep(ol) {
margin: 4px 0;
padding-left: 20px;
}
.preview-content :deep(li) {
margin: 2px 0;
}
.preview-content :deep(blockquote) {
border-left: 4px solid #42b983;
margin: 8px 0;
padding-left: 12px;
color: #666;
}
</style>

View File

@@ -0,0 +1,991 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑产品' : '新增产品'"
width="80%"
:before-close="handleClose"
class="product-form-dialog"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
class="product-form"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 基本信息 -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h3>
<el-form-item label="产品编号" prop="code">
<el-input
v-model="form.code"
placeholder="请输入产品编号"
/>
</el-form-item>
<el-form-item label="产品名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入产品名称"
/>
</el-form-item>
<el-form-item label="产品分类" prop="category_id">
<el-select
v-model="form.category_id"
placeholder="选择产品分类"
class="w-full"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</el-form-item>
<el-form-item label="产品类型" prop="is_package">
<el-radio-group v-model="form.is_package">
<el-radio :label="false">单品</el-radio>
<el-radio :label="true">组合包</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="是否启用" prop="is_enabled">
<el-switch v-model="form.is_enabled" />
</el-form-item>
<el-form-item label="是否展示" prop="is_visible">
<el-switch v-model="form.is_visible" />
</el-form-item>
</div>
<!-- 价格信息 -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">价格信息</h3>
<el-form-item label="产品价格" prop="price">
<el-input-number
v-model="form.price"
:precision="2"
:min="0"
:step="0.01"
placeholder="请输入价格"
class="w-full"
/>
</el-form-item>
<el-form-item label="成本价" prop="cost_price">
<div class="w-full">
<el-input-number
v-model="form.cost_price"
:precision="2"
:min="0"
:step="0.01"
placeholder="请输入成本价"
class="w-full"
@input="handleCostPriceInput"
/>
<div v-if="form.is_package && selectedPackageItems.length > 0" class="text-sm text-gray-500 mt-1">
<span class="text-blue-600">
子产品成本价总和¥{{ formatPrice(calculatePackageTotalCostPrice()) }}
</span>
<el-button
v-if="!isCostPriceAutoCalculated"
type="text"
size="small"
@click="applyAutoCostPrice"
class="ml-2"
>
应用自动计算
</el-button>
<span v-else class="text-green-600 ml-2">已自动计算</span>
</div>
</div>
</el-form-item>
</div>
</div>
<!-- 组合包配置 -->
<div v-if="form.is_package" class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">组合包配置</h3>
<div class="package-config-container">
<!-- 子产品选择器 -->
<div class="package-selector mb-6">
<h4 class="text-md font-medium text-gray-700 mb-3">选择子产品</h4>
<!-- 筛选条件 -->
<div class="filter-section mb-4">
<el-input
v-model="packageSelector.keyword"
placeholder="搜索产品名称或编号"
clearable
@input="handlePackageSearch"
class="mb-3"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="packageSelector.category_id"
placeholder="选择分类筛选"
clearable
@change="handlePackageSearch"
class="w-full"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</div>
<!-- 可选产品列表 -->
<div class="available-products-section">
<div class="text-sm text-gray-600 mb-2">可选产品 ({{ availableProducts.length }})</div>
<div class="available-products-container">
<div
v-for="product in availableProducts"
:key="product.id"
class="available-product-item"
:class="{ 'selected': isProductSelected(product.id) }"
@click="selectProduct(product)"
>
<div class="product-name">{{ product.name }}</div>
<div class="product-code">{{ product.code }}</div>
<div class="product-price">¥{{ formatPrice(product.price) }}</div>
<div v-if="product.cost_price" class="product-cost-price text-gray-600 text-sm">
成本价¥{{ formatPrice(product.cost_price) }}
</div>
<div class="product-action">
<el-button
v-if="!isProductSelected(product.id)"
type="primary"
size="small"
@click.stop="selectProduct(product)"
>
添加
</el-button>
<el-button
v-else
type="danger"
size="small"
@click.stop="removeSelectedProduct(product.id)"
>
移除
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- 已选子产品列表 -->
<div class="selected-products-section">
<h4 class="text-md font-medium text-gray-700 mb-3">
已选子产品 ({{ selectedPackageItems.length }})
<span class="text-sm text-gray-500 ml-2">拖拽调整顺序</span>
</h4>
<draggable
v-model="selectedPackageItems"
item-key="id"
@end="handleReorder"
class="selected-products-list"
ghost-class="ghost-item"
chosen-class="chosen-item"
>
<template #item="{ element: item }">
<div class="selected-product-item">
<div class="drag-handle">
<el-icon><Rank /></el-icon>
</div>
<div class="product-info">
<div class="product-name">{{ item.product_name }}</div>
<div class="product-code">{{ item.product_code }}</div>
<div class="product-price">¥{{ formatPrice(item.price) }}</div>
<div v-if="item.cost_price" class="product-cost-price text-gray-600 text-sm">
成本价¥{{ formatPrice(item.cost_price) }}
</div>
</div>
<div class="product-actions">
<el-button
type="danger"
size="small"
@click="removePackageItem(item)"
>
删除
</el-button>
</div>
</div>
</template>
</draggable>
<!-- 组合包总价参考 -->
<div class="package-total-section">
<div class="package-total">
<span class="total-label">子产品价格参考</span>
<span class="total-value">¥{{ calculatePackageTotalPrice() }}</span>
<span class="total-note">仅供参考实际价格以产品定价为准</span>
</div>
</div>
</div>
</div>
</div>
<!-- 产品描述 -->
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">产品描述</h3>
<el-form-item label="产品简介" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入产品简介"
/>
</el-form-item>
<el-form-item label="产品内容" prop="content">
<RichTextEditor v-model="form.content" style="width:100%" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息(仅管理员可见)"
maxlength="1000"
show-word-limit
/>
</el-form-item>
</div>
<!-- SEO信息 -->
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">SEO信息</h3>
<el-form-item label="SEO标题" prop="seo_title">
<el-input
v-model="form.seo_title"
placeholder="请输入SEO标题"
/>
</el-form-item>
<el-form-item label="SEO描述" prop="seo_description">
<el-input
v-model="form.seo_description"
type="textarea"
:rows="3"
placeholder="请输入SEO描述"
/>
</el-form-item>
<el-form-item label="SEO关键词" prop="seo_keywords">
<el-input
v-model="form.seo_keywords"
placeholder="请输入SEO关键词多个关键词用逗号分隔"
/>
</el-form-item>
</div>
</el-form>
<!-- 操作按钮 -->
<template #footer>
<div class="flex justify-end gap-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? '保存修改' : '创建产品' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { productAdminApi } from '@/api'
import RichTextEditor from '@/components/common/RichTextEditor.vue'
import { Rank, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import draggable from 'vuedraggable'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
product: {
type: Object,
default: null
},
categories: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const visible = ref(false)
const submitting = ref(false)
const formRef = ref(null)
// 组合包相关数据
const availableProducts = ref([])
const selectedPackageItems = ref([])
const packageSelector = reactive({
keyword: '',
category_id: ''
})
// 成本价自动计算相关
const isCostPriceAutoCalculated = ref(false)
const lastAutoCalculatedCostPrice = ref(0)
// 搜索防抖
let packageSearchTimer = null
// 表单数据 - 严格按照后端CreateProductCommand和UpdateProductCommand的字段
const form = reactive({
code: '',
name: '',
description: '',
content: '',
category_id: '',
price: 0,
cost_price: 0,
remark: '',
is_enabled: true,
is_visible: true,
is_package: false,
seo_title: '',
seo_description: '',
seo_keywords: ''
})
// 表单验证规则
const rules = {
code: [
{ required: true, message: '请输入产品编号', trigger: 'blur' },
{ min: 2, max: 20, message: '产品编号长度在 2 到 20 个字符', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入产品名称', trigger: 'blur' },
{ min: 2, max: 50, message: '产品名称长度在 2 到 50 个字符', trigger: 'blur' }
],
category_id: [
{ required: true, message: '请选择产品分类', trigger: 'change' }
],
price: [
{ required: true, message: '请输入产品价格', trigger: 'blur' },
{ type: 'number', min: 0, message: '价格不能小于0', trigger: 'blur' }
],
cost_price: [
{ type: 'number', min: 0, message: '成本价不能小于0', trigger: 'blur' }
],
remark: [
{ max: 1000, message: '备注长度不能超过1000个字符', trigger: 'blur' }
],
description: [
{ required: true, message: '请输入产品简介', trigger: 'blur' },
{ min: 10, max: 500, message: '产品简介长度在 10 到 500 个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入产品内容', trigger: 'blur' }
]
}
// 计算属性
const isEdit = computed(() => !!props.product)
// 监听modelValue变化
watch(() => props.modelValue, async (newVal) => {
visible.value = newVal
if (newVal) {
await initForm()
}
})
// 监听visible变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 初始化表单
const initForm = async () => {
console.log('开始初始化表单,产品数据:', props.product)
// 重置所有状态
resetFormState()
if (props.product) {
// 编辑模式
await handleEditMode()
} else {
// 新增模式
handleCreateMode()
}
}
// 重置表单状态
const resetFormState = () => {
// 重置组合包选择器
packageSelector.keyword = ''
packageSelector.category_id = ''
availableProducts.value = []
selectedPackageItems.value = []
}
// 处理编辑模式
const handleEditMode = async () => {
console.log('处理编辑模式')
// 如果是组合包,先获取完整的产品详情(包含子产品信息)
if (props.product.is_package) {
await loadProductDetail()
}
// 填充表单数据
Object.keys(form).forEach(key => {
if (props.product[key] !== undefined) {
form[key] = props.product[key]
}
})
// 如果是组合包,处理子产品数据
if (props.product.is_package) {
await handlePackageData()
}
}
// 加载产品详情(包含组合包子产品信息)
const loadProductDetail = async () => {
try {
console.log('加载产品详情产品ID:', props.product.id)
const response = await productAdminApi.getProductDetail(props.product.id)
console.log('产品详情响应:', response)
// 更新产品数据,包含组合包子产品信息
Object.assign(props.product, response.data)
console.log('更新后的产品数据:', props.product)
} catch (error) {
console.error('加载产品详情失败:', error)
ElMessage.error('加载产品详情失败')
}
}
// 处理新增模式
const handleCreateMode = () => {
console.log('处理新增模式')
// 重置表单为默认值
Object.keys(form).forEach(key => {
if (key === 'is_package') {
form[key] = false
} else if (key === 'is_enabled' || key === 'is_visible') {
form[key] = true
} else if (key === 'price') {
form[key] = 0
} else {
form[key] = ''
}
})
}
// 处理组合包数据
const handlePackageData = async () => {
console.log('处理组合包数据')
console.log('完整的产品数据:', props.product)
console.log('package_items 字段:', props.product.package_items)
console.log('package_items 类型:', typeof props.product.package_items)
console.log('package_items 长度:', props.product.package_items?.length)
// 1. 处理已选子产品
if (props.product.package_items && props.product.package_items.length > 0) {
console.log('原始组合包子产品数据:', props.product.package_items)
selectedPackageItems.value = props.product.package_items.map((item, index) => ({
id: item.id || `temp_${Date.now()}_${index}`,
product_id: item.product_id,
product_name: item.product_name || item.ProductName || '未知产品',
product_code: item.product_code || item.ProductCode || '',
price: item.price || item.Price || 0,
cost_price: item.cost_price || 0, // 保存成本价
sort_order: item.sort_order || item.SortOrder || index + 1
}))
// 检查成本价是否与子产品成本价总和一致
const totalCostPrice = calculatePackageTotalCostPrice()
if (Math.abs(form.cost_price - totalCostPrice) < 0.01) {
isCostPriceAutoCalculated.value = true
} else {
isCostPriceAutoCalculated.value = false
}
console.log('处理后的已选子产品:', selectedPackageItems.value)
} else {
console.log('没有找到组合包子产品数据')
console.log('可能的原因:')
console.log('1. 后端没有返回 package_items 字段')
console.log('2. package_items 是空数组')
console.log('3. 字段名不匹配')
selectedPackageItems.value = []
}
// 2. 加载可选产品
await loadAvailableProducts()
}
// 监听产品类型变化
watch(() => form.is_package, (newVal, oldVal) => {
console.log('产品类型变化:', { newVal, oldVal, isEdit: isEdit.value })
if (newVal && !oldVal) {
// 从非组合包变为组合包时,加载可选产品(包括编辑模式)
console.log('切换到组合包,加载可选产品')
loadAvailableProducts()
// 重置成本价自动计算状态
isCostPriceAutoCalculated.value = false
} else if (!newVal) {
// 从组合包变为非组合包时,清空相关数据
console.log('取消组合包,清空相关数据')
selectedPackageItems.value = []
availableProducts.value = []
isCostPriceAutoCalculated.value = false
}
})
// 监听已选子产品变化,自动更新成本价
watch(() => selectedPackageItems.value.length, () => {
if (form.is_package && isCostPriceAutoCalculated.value) {
updateCostPriceFromPackageItems()
}
})
// 加载可选产品
const loadAvailableProducts = async () => {
try {
console.log('开始加载可选产品')
const params = {
exclude_package_id: props.product?.id || '',
keyword: packageSelector.keyword,
category_id: packageSelector.category_id,
page: 1,
page_size: 50
}
console.log('请求参数:', params)
const response = await productAdminApi.getAvailableProducts(params)
console.log('可选产品响应:', response)
availableProducts.value = response.data?.items || []
console.log('设置可选产品列表:', availableProducts.value.length, '个产品')
} catch (error) {
console.error('加载可选产品失败:', error)
ElMessage.error('加载可选产品失败')
}
}
// 处理组合包搜索
const handlePackageSearch = () => {
if (packageSearchTimer) {
clearTimeout(packageSearchTimer)
}
packageSearchTimer = setTimeout(() => {
loadAvailableProducts()
}, 500)
}
// 检查产品是否已选择
const isProductSelected = (productId) => {
return selectedPackageItems.value.some(item => item.product_id === productId)
}
// 选择产品
const selectProduct = (product) => {
console.log('选择产品:', product)
if (isProductSelected(product.id)) {
console.log('产品已选择,跳过')
return
}
const newItem = {
id: `temp_${Date.now()}_${Math.random()}`, // 临时ID
product_id: product.id,
product_name: product.name,
product_code: product.code,
price: product.price,
cost_price: product.cost_price || 0, // 保存成本价
sort_order: selectedPackageItems.value.length + 1
}
selectedPackageItems.value.push(newItem)
console.log('添加产品到已选列表,当前已选数量:', selectedPackageItems.value.length)
// 如果是组合包,自动计算成本价(首次选择时自动启用)
if (form.is_package) {
if (!isCostPriceAutoCalculated.value && selectedPackageItems.value.length === 1) {
// 首次选择子产品时,自动启用自动计算
isCostPriceAutoCalculated.value = true
}
if (isCostPriceAutoCalculated.value) {
updateCostPriceFromPackageItems()
}
}
}
// 移除已选产品
const removeSelectedProduct = (productId) => {
selectedPackageItems.value = selectedPackageItems.value.filter(
item => item.product_id !== productId
)
// 如果是组合包,自动计算成本价
if (form.is_package && isCostPriceAutoCalculated.value) {
updateCostPriceFromPackageItems()
}
}
// 移除组合包项目
const removePackageItem = (item) => {
selectedPackageItems.value = selectedPackageItems.value.filter(
selectedItem => selectedItem.id !== item.id
)
// 如果是组合包,自动计算成本价
if (form.is_package && isCostPriceAutoCalculated.value) {
updateCostPriceFromPackageItems()
}
}
// 处理拖拽排序
const handleReorder = () => {
// 重新设置排序号
selectedPackageItems.value.forEach((item, index) => {
item.sort_order = index + 1
})
}
// 计算组合包总价参考
const calculatePackageTotalPrice = () => {
const total = selectedPackageItems.value.reduce((sum, item) => {
return sum + (item.price || 0)
}, 0)
return formatPrice(total)
}
// 计算组合包成本价总和
const calculatePackageTotalCostPrice = () => {
const total = selectedPackageItems.value.reduce((sum, item) => {
return sum + (item.cost_price || 0)
}, 0)
return total
}
// 从子产品更新成本价
const updateCostPriceFromPackageItems = () => {
if (!form.is_package || selectedPackageItems.value.length === 0) {
return
}
const totalCostPrice = calculatePackageTotalCostPrice()
form.cost_price = totalCostPrice
lastAutoCalculatedCostPrice.value = totalCostPrice
isCostPriceAutoCalculated.value = true
}
// 应用自动计算的成本价
const applyAutoCostPrice = () => {
updateCostPriceFromPackageItems()
}
// 处理成本价输入变化
const handleCostPriceInput = () => {
// 如果用户手动修改了成本价,且与自动计算的值不同,则标记为非自动计算
const autoCalculated = calculatePackageTotalCostPrice()
if (Math.abs(form.cost_price - autoCalculated) > 0.01) {
isCostPriceAutoCalculated.value = false
} else {
isCostPriceAutoCalculated.value = true
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 关闭弹窗
const handleClose = () => {
visible.value = false
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
// 组合包验证
if (form.is_package && selectedPackageItems.value.length === 0) {
ElMessage.error('组合包必须包含至少一个子产品')
return
}
submitting.value = true
const submitData = { ...form }
if (isEdit.value) {
// 编辑模式
await productAdminApi.updateProduct(props.product.id, submitData)
// 如果是组合包,处理子产品
if (form.is_package) {
await handlePackageItemsUpdate(props.product.id)
}
ElMessage.success('产品更新成功')
} else {
// 新增模式
const response = await productAdminApi.createProduct(submitData)
// 如果是组合包,添加子产品
if (form.is_package && response.data?.id) {
await handlePackageItemsUpdate(response.data.id)
}
ElMessage.success('产品创建成功')
}
emit('success')
} catch (error) {
if (error !== false) { // 不是表单验证错误
console.error('提交失败:', error)
ElMessage.error('操作失败')
}
} finally {
submitting.value = false
}
}
// 处理组合包子产品更新
const handlePackageItemsUpdate = async (packageId) => {
// 准备批量更新的数据
const items = selectedPackageItems.value.map((item, index) => ({
product_id: item.product_id,
sort_order: index + 1
}))
// 批量更新组合包子产品
try {
await productAdminApi.updatePackageItems(packageId, { items })
} catch (error) {
console.error('批量更新子产品失败:', error)
throw new Error('批量更新子产品失败')
}
}
</script>
<style scoped>
.product-form-dialog :deep(.el-dialog__body) {
padding: 20px 24px;
}
.product-form {
max-height: 70vh;
overflow-y: auto;
}
/* 组合包配置样式 */
.package-config-container {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
background-color: #f9fafb;
}
.available-products-container {
max-height: 300px;
overflow-y: auto;
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: white;
}
.available-product-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background-color 0.2s;
}
.available-product-item:hover {
background-color: #f3f4f6;
}
.available-product-item.selected {
background-color: #dbeafe;
border-left: 3px solid #3b82f6;
}
.available-product-item .product-name {
font-weight: 500;
color: #111827;
flex: 1;
margin-right: 12px;
}
.available-product-item .product-code {
font-size: 12px;
color: #6b7280;
margin-right: 12px;
min-width: 80px;
}
.available-product-item .product-price {
font-size: 14px;
color: #dc2626;
font-weight: 500;
margin-right: 12px;
min-width: 80px;
}
.selected-products-list {
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: white;
min-height: 100px;
}
.selected-product-item {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f3f4f6;
background-color: white;
transition: background-color 0.2s;
}
.selected-product-item:hover {
background-color: #f9fafb;
}
.selected-product-item:last-child {
border-bottom: none;
}
.drag-handle {
cursor: grab;
color: #9ca3af;
margin-right: 12px;
padding: 4px;
}
.drag-handle:hover {
color: #6b7280;
}
.selected-product-item .product-info {
flex: 1;
margin-right: 16px;
}
.selected-product-item .product-name {
font-weight: 500;
color: #111827;
margin-bottom: 4px;
}
.selected-product-item .product-code {
font-size: 12px;
color: #6b7280;
margin-bottom: 2px;
}
.selected-product-item .product-price {
font-size: 14px;
color: #dc2626;
font-weight: 500;
}
.selected-product-item .product-actions {
margin-left: auto;
}
.package-total-section {
margin-top: 16px;
padding: 16px;
background-color: #fef3c7;
border-radius: 6px;
border: 1px solid #f59e0b;
}
.package-total {
text-align: right;
}
.package-total .total-label {
font-size: 16px;
font-weight: 500;
color: #92400e;
}
.package-total .total-value {
font-size: 18px;
font-weight: 600;
color: #dc2626;
margin-left: 8px;
}
.package-total .total-note {
font-size: 12px;
color: #6b7280;
margin-left: 8px;
font-style: italic;
}
/* 拖拽样式 */
.ghost-item {
opacity: 0.5;
background-color: #e5e7eb;
}
.chosen-item {
background-color: #dbeafe;
border: 2px dashed #3b82f6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.selected-product-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.available-product-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.available-product-item .product-action {
align-self: flex-end;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div v-if="hasPermission">
<slot></slot>
</div>
<div v-else-if="showFallback" class="permission-denied">
<el-empty
:image-size="100"
description="权限不足"
>
<template #description>
<p>您没有访问此功能的权限</p>
<p class="permission-tip">请联系管理员获取相应权限</p>
</template>
<el-button
type="primary"
@click="requestPermission"
v-if="showRequestButton"
>
申请权限
</el-button>
</el-empty>
</div>
</template>
<script>
export default {
name: 'PermissionGuard',
props: {
// 需要的权限角色
roles: {
type: Array,
default: () => []
},
// 需要的权限
permissions: {
type: Array,
default: () => []
},
// 是否显示无权限时的内容
showFallback: {
type: Boolean,
default: true
},
// 是否显示申请权限按钮
showRequestButton: {
type: Boolean,
default: true
}
},
computed: {
// 当前用户角色
currentUserRole() {
return this.$store.getters.userRole || 'user'
},
// 当前用户权限
currentUserPermissions() {
return this.$store.getters.userPermissions || []
},
// 是否有权限
hasPermission() {
// 检查角色权限
if (this.roles.length > 0) {
if (!this.roles.includes(this.currentUserRole)) {
return false
}
}
// 检查功能权限
if (this.permissions.length > 0) {
const hasAllPermissions = this.permissions.every(permission =>
this.currentUserPermissions.includes(permission)
)
if (!hasAllPermissions) {
return false
}
}
return true
}
},
methods: {
// 申请权限
requestPermission() {
this.$emit('request-permission', {
roles: this.roles,
permissions: this.permissions,
userRole: this.currentUserRole
})
}
}
}
</script>
<style scoped>
.permission-denied {
padding: 40px 20px;
text-align: center;
}
.permission-tip {
color: #909399;
font-size: 14px;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div v-if="visible" class="app-loading-overlay">
<div class="app-loading-container">
<!-- Logo动画 -->
<div class="app-loading-logo">
<div class="logo-container">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="logo-text">天远数据平台</div>
</div>
</div>
<!-- 加载动画 -->
<div class="loading-spinner">
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
<!-- 加载文本 -->
<div class="loading-text">
<span v-if="message">{{ message }}</span>
<span v-else>正在加载...</span>
</div>
</div>
</div>
</template>
<script setup>
defineModel('visible', {
type: Boolean,
default: false
})
</script>
<style scoped>
.app-loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(248, 250, 252, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.app-loading-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
}
.app-loading-logo {
animation: logoFloat 2s ease-in-out infinite;
}
.logo-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.logo-icon {
width: 4rem;
height: 4rem;
color: #3b82f6;
animation: logoPulse 2s ease-in-out infinite;
}
.logo-icon svg {
width: 100%;
height: 100%;
}
.logo-text {
font-size: 1.5rem;
font-weight: bold;
color: #1e293b;
letter-spacing: 0.2em;
}
.loading-spinner {
position: relative;
width: 3rem;
height: 3rem;
}
.spinner-ring {
position: absolute;
width: 100%;
height: 100%;
border: 3px solid transparent;
border-top: 3px solid #3b82f6;
border-radius: 50%;
animation: spin 1.2s linear infinite;
}
.spinner-ring:nth-child(1) {
animation-delay: 0s;
}
.spinner-ring:nth-child(2) {
width: 80%;
height: 80%;
top: 10%;
left: 10%;
animation-delay: 0.2s;
animation-duration: 1.4s;
}
.spinner-ring:nth-child(3) {
width: 60%;
height: 60%;
top: 20%;
left: 20%;
animation-delay: 0.4s;
animation-duration: 1.6s;
}
.loading-text {
color: #64748b;
font-size: 1rem;
font-weight: 500;
text-align: center;
animation: textFade 2s ease-in-out infinite;
}
@keyframes logoFloat {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes logoPulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes textFade {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* 性能优化:减少动画效果 */
@media (prefers-reduced-motion: reduce) {
.app-loading-logo,
.logo-icon,
.spinner-ring,
.loading-text {
animation: none !important;
}
}
/* 性能优化:低端设备简化 */
@media (max-width: 640px) {
.logo-icon {
width: 3rem;
height: 3rem;
}
.logo-text {
font-size: 1.25rem;
}
.loading-spinner {
width: 2.5rem;
height: 2.5rem;
}
.loading-text {
font-size: 0.875rem;
}
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<el-dialog
v-model="dialogVisible"
title="商务洽谈"
width="500px"
:close-on-click-modal="true"
:close-on-press-escape="true"
class="business-consultation-dialog"
:z-index="9999"
>
<div class="consultation-content">
<div class="consultation-info">
<h4>专属商务顾问</h4>
<p>扫描下方二维码添加专属商务顾问微信</p>
<p class="consultation-benefits">享受以下专属服务</p>
<ul class="grid grid-cols-2 gap-x-6 gap-y-2 benefits-list">
<li>一对一专属服务</li>
<li>定制化解决方案</li>
<li>优先技术支持</li>
<li>专属价格优惠</li>
</ul>
</div>
<div class="qr-code-container">
<div class="qr-code-wrapper">
<img
src="/qrcode.jpg"
alt="商务洽谈二维码"
class="qr-code-image"
@error="handleQrCodeError"
/>
<div v-if="qrCodeError" class="qr-code-placeholder">
<i class="el-icon-chat-dot-round"></i>
<span>二维码加载失败</span>
</div>
</div>
<p class="qr-code-tip">请使用微信扫描二维码</p>
<!-- INSERT_YOUR_CODE -->
<el-button
class="mt-6"
type="primary"
@click="closeDialog"
>
关闭
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
// Emits
const emit = defineEmits(['update:visible'])
// 响应式数据
const dialogVisible = ref(false)
const qrCodeError = ref(false)
// 监听visible属性变化
watch(() => props.visible, (newVal) => {
if (newVal !== dialogVisible.value) {
dialogVisible.value = newVal
}
})
// 监听对话框状态变化
watch(dialogVisible, (newVal) => {
if (newVal !== props.visible) {
emit('update:visible', newVal)
}
})
// 处理二维码加载错误
const handleQrCodeError = () => {
qrCodeError.value = true
}
// 关闭对话框
const closeDialog = () => {
dialogVisible.value = false
}
</script>
<style scoped>
.business-consultation-dialog {
border-radius: 12px;
}
.consultation-content {
text-align: center;
padding: 20px 0;
}
.consultation-info h4 {
color: #303133;
margin-bottom: 12px;
font-size: 18px;
font-weight: 600;
}
.consultation-info p {
color: #606266;
margin-bottom: 8px;
font-size: 14px;
line-height: 1.5;
}
.consultation-benefits {
color: #409eff !important;
font-weight: 500;
margin-top: 16px !important;
}
.benefits-list {
list-style: none;
padding: 0;
margin: 12px 0 20px 0;
text-align: left;
}
.benefits-list li {
color: #606266;
font-size: 14px;
line-height: 1.6;
margin-bottom: 6px;
padding-left: 20px;
position: relative;
}
.benefits-list li::before {
content: "✓";
position: absolute;
left: 0;
color: #67c23a;
font-weight: bold;
}
.qr-code-container {
margin-top: 24px;
}
.qr-code-wrapper {
position: relative;
display: inline-block;
margin-bottom: 12px;
}
.qr-code-image {
width: 200px;
border-radius: 8px;
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.qr-code-placeholder {
width: 160px;
height: 160px;
border-radius: 8px;
border: 1px solid #e4e7ed;
background-color: #f5f7fa;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
}
.qr-code-placeholder i {
font-size: 32px;
margin-bottom: 8px;
}
.qr-code-placeholder span {
font-size: 12px;
}
.qr-code-tip {
color: #909399;
font-size: 12px;
margin: 0;
}
.dialog-footer {
text-align: center;
}
/* 响应式设计 */
@media (max-width: 480px) {
.business-consultation-dialog {
width: 90% !important;
margin: 0 auto;
}
.qr-code-image,
.qr-code-placeholder {
width: 120px;
height: 120px;
}
.consultation-content {
padding: 16px 0;
}
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div v-if="showBanner" class="certification-banner">
<el-alert
type="warning"
:closable="false"
show-icon
class="banner-alert"
>
<template #default>
<div class="banner-content">
<span class="banner-message">
您尚未完成企业认证无法享受全部API调用服务请先完成企业入驻完成认证即可领取额度奖励
</span>
<el-button
type="primary"
size="small"
@click="goToCertification"
class="certify-btn"
>
<el-icon class="mr-1">
<ShieldCheckIcon />
</el-icon>
立即认证
</el-button>
</div>
</template>
</el-alert>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { ShieldCheckIcon } from '@heroicons/vue/24/outline'
const props = defineProps({
// 是否强制显示横幅(用于测试)
forceShow: {
type: Boolean,
default: false
}
})
const router = useRouter()
const userStore = useUserStore()
// 计算是否显示横幅
const showBanner = computed(() => {
if (props.forceShow) return true
// 如果用户已登录但未认证,且不在管理员页面,显示横幅
const isAdminPage = router.currentRoute.value.path.startsWith('/admin')
return userStore.isLoggedIn && !userStore.userInfo?.is_certified && !isAdminPage
})
// 跳转到企业认证页面
const goToCertification = () => {
router.push('/profile/certification')
}
</script>
<style scoped>
.certification-banner {
width: 100%;
margin-bottom: 0;
}
.banner-alert {
border-radius: 0;
border: none;
border-bottom: 1px solid #f0f0f0;
box-shadow: none;
padding: 8px 16px;
min-height: auto;
}
.banner-alert :deep(.el-alert__content) {
width: 100%;
padding: 0;
}
.banner-alert :deep(.el-alert__icon) {
font-size: 16px;
margin-right: 8px;
}
.banner-content {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
width: 100%;
}
.banner-message {
font-size: 14px;
font-weight: 500;
color: #e6a23c;
margin: 0;
line-height: 1.4;
}
.certify-btn {
font-weight: 500;
padding: 6px 12px;
font-size: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.banner-content {
flex-direction: column;
align-items: center;
gap: 8px;
}
.certify-btn {
justify-content: center;
}
}
@media (max-width: 480px) {
.certify-btn {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div v-if="showNotice" class="certification-notice">
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-3 flex-1">
<h3 class="text-sm font-medium text-blue-800">
{{ title || '企业认证提示' }}
</h3>
<div class="mt-2 text-sm text-blue-700">
<p>
{{ description || '为了享受完整的API调用服务请先完成企业入驻认证。完成认证即可领取额度奖励' }}
</p>
</div>
<div class="mt-3">
<router-link
to="/profile/certification"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
>
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
立即认证
</router-link>
</div>
</div>
<div class="ml-auto pl-3">
<button
@click="dismissNotice"
class="inline-flex text-blue-400 hover:text-blue-600 focus:outline-none focus:text-blue-600 transition-colors duration-200"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CertificationNotice',
props: {
// 是否显示提示
show: {
type: Boolean,
default: true
},
// 自定义标题
title: {
type: String,
default: ''
},
// 自定义描述
description: {
type: String,
default: ''
}
},
data() {
return {
dismissed: false
}
},
computed: {
showNotice() {
return this.show && !this.dismissed
}
},
methods: {
dismissNotice() {
this.dismissed = true
}
},
mounted() {
}
}
</script>
<style scoped>
.certification-notice {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,399 @@
<template>
<div class="code-display h-full flex flex-col">
<!-- 加载状态 -->
<div v-if="loading" class="flex items-center justify-center p-8 flex-1">
<el-skeleton :rows="10" animated />
</div>
<!-- 语言选择标签页 -->
<div v-else class="flex flex-col h-full">
<div class="flex-shrink-0 mb-4">
<el-tabs
v-model="activeLanguage"
type="card"
@tab-click="handleTabClick"
class="custom-tabs"
>
<el-tab-pane
v-for="lang in languages"
:key="lang.key"
:label="lang.label"
:name="lang.key"
>
<template #label>
<div class="flex items-center gap-2 px-1">
<span class="text-sm font-medium">{{ lang.label }}</span>
<span class="text-xs text-gray-400 font-mono">{{ lang.extension }}</span>
</div>
</template>
</el-tab-pane>
</el-tabs>
</div>
<!-- 代码内容区域 - 可滚动 -->
<div class="flex-1 min-h-0 flex flex-col">
<div class="flex justify-between items-center mb-3 flex-shrink-0">
<h4 class="text-sm font-semibold text-gray-800 m-0">
{{ languages.find(l => l.key === activeLanguage)?.label }} 示例代码
</h4>
<div class="flex gap-2">
<el-button
type="primary"
size="small"
@click="copyCode(activeLanguage)"
class="text-xs"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
复制代码
</el-button>
<el-button
type="success"
size="small"
@click="downloadCurrentCode"
:disabled="!hasExampleCodes"
class="text-xs"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
下载代码
</el-button>
</div>
</div>
<!-- 代码内容 - 可滚动 -->
<div class="flex-1 bg-gray-900 rounded-lg overflow-hidden">
<div class="h-full overflow-auto p-4">
<pre class="text-sm text-gray-100 m-0 font-mono leading-relaxed whitespace-pre-wrap"><code>{{ getCodeContent(activeLanguage) }}</code></pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
// Props
const props = defineProps({
// 移除 productCode因为这是通用的API示例代码
})
// 响应式数据
const activeLanguage = ref('go')
const exampleCodes = ref({})
const loading = ref(false)
// 支持的语言列表
const languages = [
{ key: 'go', label: 'Go', extension: '.go', filename: 'demo.go' },
{ key: 'java', label: 'Java', extension: '.java', filename: 'demo.java' },
{ key: 'nodejs', label: 'Node.js', extension: '.js', filename: 'demo.js' },
{ key: 'csharp', label: 'C#', extension: '.cs', filename: 'demo.cs' },
{ key: 'python', label: 'Python', extension: '.py', filename: 'demo.py' },
{ key: 'php', label: 'PHP', extension: '.php', filename: 'demo.php' }
]
// 计算属性
const hasExampleCodes = computed(() => {
return Object.keys(exampleCodes.value).length > 0
})
// 生命周期
onMounted(() => {
loadExampleCodes()
})
// 方法
const handleTabClick = (tab) => {
activeLanguage.value = tab.name
}
const loadExampleCodes = async () => {
loading.value = true
try {
// 并行加载所有语言的示例代码
const promises = languages.map(async (lang) => {
try {
const code = await loadCodeFile(lang.key, lang.filename)
return { key: lang.key, code }
} catch (error) {
console.warn(`加载${lang.label}示例代码失败:`, error)
return { key: lang.key, code: null }
}
})
const results = await Promise.all(promises)
// 构建示例代码对象
results.forEach(result => {
if (result.code) {
exampleCodes.value[result.key] = result.code
}
})
} catch (error) {
console.error('加载示例代码失败:', error)
ElMessage.error('加载示例代码失败')
} finally {
loading.value = false
}
}
const loadCodeFile = async (langKey, filename) => {
try {
// 从 public 目录加载示例代码文件
const response = await fetch(`/examples/${langKey}/${filename}`)
if (response.ok) {
return await response.text()
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
} catch (error) {
console.warn(`加载${filename}失败:`, error)
// 如果加载失败,返回默认的示例代码
return getDefaultExampleCode(langKey)
}
}
const getDefaultExampleCode = (langKey) => {
const defaultCodes = {
go: `package main
import (
"fmt"
"net/http"
)
func main() {
fmt.Println("Go示例代码加载中...")
// 请检查源码文件是否存在
}`,
java: `public class ApiClient {
public static void main(String[] args) {
System.out.println("Java示例代码加载中...");
// 请检查源码文件是否存在
}
}`,
nodejs: `const ApiClient = require('./api-client');
console.log('Node.js示例代码加载中...');
// 请检查源码文件是否存在`,
csharp: `using System;
public class ApiClient {
public static void Main(string[] args) {
Console.WriteLine("C#示例代码加载中...");
// 请检查源码文件是否存在
}
}`,
python: `#!/usr/bin/env python3
class ApiClient:
def __init__(self):
print("Python示例代码加载中...")
# 请检查源码文件是否存在
if __name__ == "__main__":
client = ApiClient()`,
php: `<?php
class ApiClient {
public function __construct() {
echo "PHP示例代码加载中...\n";
// 请检查源码文件是否存在
}
}
$client = new ApiClient();`
}
return defaultCodes[langKey] || '// 示例代码加载中...'
}
const getCodeContent = (langKey) => {
const code = exampleCodes.value[langKey]
if (!code) {
return `// 暂无 ${languages.find(l => l.key === langKey)?.label} 示例代码`
}
// 过滤掉 source map 注释行
return code.split('\n')
.filter(line => !line.trim().startsWith('//# sourceMappingURL='))
.join('\n')
}
const copyCode = async (langKey) => {
const code = getCodeContent(langKey)
try {
await navigator.clipboard.writeText(code)
ElMessage.success(`${languages.find(l => l.key === langKey)?.label} 代码已复制到剪贴板`)
} catch (error) {
console.error('复制失败:', error)
ElMessage.error('复制失败,请手动复制')
}
}
const downloadCurrentCode = () => {
if (!hasExampleCodes.value) {
ElMessage.warning('暂无示例代码可下载')
return
}
const lang = languages.find(l => l.key === activeLanguage.value)
if (!lang) {
ElMessage.error('未找到当前语言的示例代码')
return
}
const code = getCodeContent(activeLanguage.value)
if (code && !code.includes('暂无') && !code.includes('加载中')) {
// 过滤掉 source map 注释行,确保下载的文件也不包含
const cleanCode = code.split('\n')
.filter(line => !line.trim().startsWith('//# sourceMappingURL='))
.join('\n')
const blob = new Blob([cleanCode], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `天远数据API_Demo_${lang.label}${lang.extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success(`${lang.label} 代码已下载`)
} else {
ElMessage.warning('当前语言暂无示例代码或加载失败')
}
}
</script>
<style scoped>
.code-display {
min-height: 400px;
}
/* 自定义标签页样式 */
:deep(.custom-tabs) {
--el-tabs-header-height: 48px;
}
:deep(.custom-tabs .el-tabs__header) {
margin-bottom: 0;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
border-radius: 8px 8px 0 0;
padding: 0 16px;
}
:deep(.custom-tabs .el-tabs__nav-wrap) {
padding: 0;
}
:deep(.custom-tabs .el-tabs__nav) {
border: none;
background: transparent;
}
:deep(.custom-tabs .el-tabs__item) {
height: 48px;
line-height: 48px;
font-size: 14px;
font-weight: 500;
color: #6b7280;
background: transparent;
border: none;
border-radius: 6px 6px 0 0;
margin-right: 4px;
padding: 0 16px;
transition: all 0.2s ease;
position: relative;
}
:deep(.custom-tabs .el-tabs__item:hover) {
color: #3b82f6;
background: rgba(59, 130, 246, 0.05);
}
:deep(.custom-tabs .el-tabs__item.is-active) {
color: #3b82f6;
background: white;
border-bottom: 2px solid #3b82f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
:deep(.custom-tabs .el-tabs__item.is-active::before) {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: #3b82f6;
border-radius: 6px 6px 0 0;
}
:deep(.custom-tabs .el-tabs__content) {
padding: 0;
background: white;
border-radius: 0 0 8px 8px;
border: 1px solid #e5e7eb;
border-top: none;
}
:deep(.custom-tabs .el-tabs__active-bar) {
display: none;
}
/* 代码区域样式 */
pre {
white-space: pre-wrap;
word-wrap: break-word;
}
/* 滚动条样式 */
.overflow-auto::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.overflow-auto::-webkit-scrollbar-track {
background: #374151;
border-radius: 4px;
}
.overflow-auto::-webkit-scrollbar-thumb {
background: #6b7280;
border-radius: 4px;
}
.overflow-auto::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 标签页内容区域 */
.overflow-auto::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.overflow-auto::-webkit-scrollbar-track {
background: #374151;
border-radius: 4px;
}
.overflow-auto::-webkit-scrollbar-thumb {
background: #6b7280;
border-radius: 4px;
}
.overflow-auto::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
</style>

View File

@@ -0,0 +1,342 @@
<template>
<div class="custom-steps">
<div class="steps-container">
<div
v-for="(step, index) in steps"
:key="step.key"
class="step-item"
:class="getStepClass(index)"
>
<!-- 步骤图标 -->
<div class="step-icon">
<component
:is="step.icon"
class="step-icon-svg"
:class="getIconClass(index)"
/>
</div>
<!-- 步骤内容 -->
<div class="step-content">
<h3 class="step-title" :class="getTitleClass(index)">
{{ step.title }}
</h3>
<p class="step-description" :class="getDescriptionClass(index)">
{{ step.description }}
</p>
</div>
<!-- 连接线 -->
<div
v-if="index < steps.length - 1"
class="step-line"
:class="getLineClass(index)"
></div>
</div>
</div>
</div>
</template>
<script setup>
// 定义props
const props = defineProps({
steps: {
type: Array,
required: true,
default: () => []
},
currentStep: {
type: [String, Number],
required: true
}
})
// 计算当前步骤索引
const currentStepIndex = computed(() => {
if (typeof props.currentStep === 'number') {
return props.currentStep
}
return props.steps.findIndex(step => step.key === props.currentStep)
})
// 获取步骤状态
const getStepStatus = (index) => {
if (index < currentStepIndex.value) {
return 'finish' // 已完成
} else if (index === currentStepIndex.value) {
return 'process' // 当前进行
} else {
return 'wait' // 未开始
}
}
// 获取步骤CSS类
const getStepClass = (index) => {
const status = getStepStatus(index)
return {
'step-item--finish': status === 'finish',
'step-item--process': status === 'process',
'step-item--wait': status === 'wait'
}
}
// 获取图标CSS类
const getIconClass = (index) => {
const status = getStepStatus(index)
return {
'step-icon-svg--finish': status === 'finish',
'step-icon-svg--process': status === 'process',
'step-icon-svg--wait': status === 'wait'
}
}
// 获取标题CSS类
const getTitleClass = (index) => {
const status = getStepStatus(index)
return {
'step-title--finish': status === 'finish',
'step-title--process': status === 'process',
'step-title--wait': status === 'wait'
}
}
// 获取描述CSS类
const getDescriptionClass = (index) => {
const status = getStepStatus(index)
return {
'step-description--finish': status === 'finish',
'step-description--process': status === 'process',
'step-description--wait': status === 'wait'
}
}
// 获取连接线CSS类
const getLineClass = (index) => {
const status = getStepStatus(index)
return {
'step-line--finish': status === 'finish',
'step-line--wait': status === 'wait' || status === 'process'
}
}
</script>
<style scoped>
.custom-steps {
width: 100%;
}
.steps-container {
display: flex;
align-items: flex-start;
justify-content: space-between;
position: relative;
padding: 0 20px;
width: 100%;
min-width: 100%;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
flex: 1;
min-width: 0;
padding: 0 10px;
}
/* 步骤图标 */
.step-icon {
width: 56px;
height: 56px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 3px solid;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 2;
background: #fff;
}
.step-icon-svg {
width: 24px;
height: 24px;
transition: all 0.3s ease;
}
/* 未完成步骤 - 灰色 */
.step-item--wait .step-icon {
background: #f1f5f9;
border-color: #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.step-icon-svg--wait {
color: #94a3b8;
}
/* 当前进行步骤 - 蓝色 */
.step-item--process .step-icon {
background: #3b82f6;
border-color: #3b82f6;
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.4);
transform: scale(1.1);
animation: pulse 2s infinite;
}
.step-icon-svg--process {
color: white;
}
/* 已完成步骤 - 绿色 */
.step-item--finish .step-icon {
background: #10b981;
border-color: #10b981;
box-shadow: 0 8px 24px rgba(16, 185, 129, 0.3);
transform: scale(1.05);
}
.step-icon-svg--finish {
color: white;
}
/* 步骤内容 */
.step-content {
margin-top: 16px;
text-align: center;
transition: all 0.3s ease;
}
.step-title {
font-size: 18px;
font-weight: 700;
margin: 0 0 8px 0;
transition: all 0.3s ease;
}
.step-description {
font-size: 14px;
font-weight: 500;
margin: 0;
line-height: 1.4;
transition: all 0.3s ease;
}
/* 未完成步骤文字 */
.step-title--wait {
color: #94a3b8;
}
.step-description--wait {
color: #cbd5e1;
}
/* 当前进行步骤文字 */
.step-title--process {
color: #1e293b;
transform: scale(1.05);
}
.step-description--process {
color: #3b82f6;
font-weight: 600;
}
/* 已完成步骤文字 */
.step-title--finish {
color: #059669;
font-weight: 700;
}
.step-description--finish {
color: #10b981;
font-weight: 600;
}
/* 连接线 */
.step-line {
position: absolute;
top: 28px;
left: calc(50% + 28px);
width: calc(100% - 56px);
height: 3px;
border-radius: 2px;
transition: all 0.3s ease;
z-index: 1;
}
.step-line--wait {
background: #e2e8f0;
}
.step-line--finish {
background: #10b981;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.2);
}
/* 脉冲动画 */
@keyframes pulse {
0% {
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.6);
}
100% {
box-shadow: 0 12px 32px rgba(59, 130, 246, 0.4);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.steps-container {
flex-direction: column;
align-items: center;
gap: 32px;
}
.step-item {
max-width: none;
width: 100%;
}
.step-line {
display: none;
}
.step-content {
margin-top: 12px;
}
.step-title {
font-size: 16px;
}
.step-description {
font-size: 13px;
}
}
@media (max-width: 480px) {
.step-icon {
width: 48px;
height: 48px;
}
.step-icon-svg {
width: 20px;
height: 20px;
}
.step-title {
font-size: 15px;
}
.step-description {
font-size: 12px;
}
}
</style>

View File

@@ -0,0 +1,205 @@
# ExportDialog 导出弹窗组件
## 概述
`ExportDialog` 是一个通用的导出数据弹窗组件,支持多种筛选条件和导出格式。该组件被设计为可复用的公共组件,用于各个管理页面的数据导出功能。
## 功能特性
-**企业选择**:支持多选、本地搜索(直接加载所有已认证企业)
-**产品选择**:支持多选、搜索、异步加载
-**充值类型选择**:支持单选筛选
-**状态选择**:支持单选筛选
-**时间范围**:支持日期时间范围选择
-**导出格式**支持Excel和CSV格式
-**异步搜索**:产品名称支持实时搜索
-**批量加载**一次性加载1000条数据提升性能
## Props
| 属性名 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `modelValue` | Boolean | false | 控制弹窗显示/隐藏 |
| `title` | String | '导出数据' | 弹窗标题 |
| `loading` | Boolean | false | 导出按钮加载状态 |
| `showCompanySelect` | Boolean | true | 是否显示企业选择 |
| `showProductSelect` | Boolean | true | 是否显示产品选择 |
| `showRechargeTypeSelect` | Boolean | false | 是否显示充值类型选择 |
| `showStatusSelect` | Boolean | false | 是否显示状态选择 |
| `showDateRange` | Boolean | true | 是否显示时间范围选择 |
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| `update:modelValue` | Boolean | 弹窗显示状态变化 |
| `confirm` | Object | 确认导出,返回筛选条件对象 |
| `cancel` | - | 取消导出 |
## 筛选条件对象结构
```javascript
{
companyIds: [], // 企业ID数组
productIds: [], // 产品ID数组
rechargeType: '', // 充值类型
status: '', // 状态
dateRange: [], // 时间范围 [startTime, endTime]
format: 'excel' // 导出格式 'excel' | 'csv'
}
```
## 使用示例
### 1. API调用记录导出
```vue
<template>
<ExportDialog
v-model="exportDialogVisible"
title="导出API调用记录"
:loading="exportLoading"
:show-company-select="true"
:show-product-select="true"
:show-recharge-type-select="false"
:show-status-select="false"
:show-date-range="true"
@confirm="handleExport"
/>
</template>
<script setup>
const handleExport = async (options) => {
const params = {
format: options.format,
user_ids: options.companyIds.join(','),
product_ids: options.productIds.join(','),
start_time: options.dateRange[0],
end_time: options.dateRange[1]
}
const response = await apiCallApi.exportAdminApiCalls(params)
// 处理下载...
}
</script>
```
### 2. 充值记录导出
```vue
<template>
<ExportDialog
v-model="exportDialogVisible"
title="导出充值记录"
:loading="exportLoading"
:show-company-select="true"
:show-product-select="false"
:show-recharge-type-select="true"
:show-status-select="true"
:show-date-range="true"
@confirm="handleExport"
/>
</template>
<script setup>
const handleExport = async (options) => {
const params = {
format: options.format,
user_ids: options.companyIds.join(','),
recharge_type: options.rechargeType,
status: options.status,
start_time: options.dateRange[0],
end_time: options.dateRange[1]
}
const response = await rechargeRecordApi.exportAdminRechargeRecords(params)
// 处理下载...
}
</script>
```
### 3. 钱包交易记录导出
```vue
<template>
<ExportDialog
v-model="exportDialogVisible"
title="导出钱包交易记录"
:loading="exportLoading"
:show-company-select="true"
:show-product-select="true"
:show-recharge-type-select="false"
:show-status-select="false"
:show-date-range="true"
@confirm="handleExport"
/>
</template>
<script setup>
const handleExport = async (options) => {
const params = {
format: options.format,
user_ids: options.companyIds.join(','),
product_ids: options.productIds.join(','),
start_time: options.dateRange[0],
end_time: options.dateRange[1]
}
const response = await walletTransactionApi.exportAdminWalletTransactions(params)
// 处理下载...
}
</script>
```
## 技术实现
### 数据加载优化
- **企业数据**一次性加载所有已认证企业最多1000条支持本地搜索
- **产品数据**:支持异步搜索,减少网络请求
- **缓存机制**:已加载的数据会被缓存,避免重复请求
### 性能优化
- **企业数据**:一次性加载,本地搜索,无需网络请求
- **产品搜索**防抖处理减少API调用
- **懒加载**:下拉框首次打开时才加载数据
- **内存管理**:组件销毁时清理事件监听器
### 错误处理
- **网络错误**API请求失败时显示友好提示
- **数据验证**:导出前验证必要参数
- **用户反馈**:操作成功/失败都有明确提示
## 样式定制
组件使用 Tailwind CSS 进行样式设计,支持以下自定义:
```css
/* 自定义弹窗样式 */
.export-dialog {
/* 自定义样式 */
}
/* 自定义选择器样式 */
.export-dialog .el-select {
/* 自定义样式 */
}
```
## 注意事项
1. **API依赖**:组件依赖 `userApi``productApi`确保这些API已正确配置
2. **权限控制**:企业列表只加载已认证用户(`is_certified: true`
3. **数据格式**:时间范围使用 `YYYY-MM-DD HH:mm:ss` 格式
4. **文件下载**:导出文件会自动下载,文件名包含时间戳
## 更新日志
### v1.0.0
- ✅ 初始版本发布
- ✅ 支持企业、产品、时间范围筛选
- ✅ 支持Excel和CSV格式导出
- ✅ 异步搜索和批量加载
- ✅ 完整的错误处理和用户反馈

View File

@@ -0,0 +1,304 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="600px"
:close-on-click-modal="false"
>
<div class="space-y-4">
<!-- 企业选择 -->
<div v-if="showCompanySelect">
<label class="block text-sm font-medium text-gray-700 mb-2">选择企业</label>
<el-select
v-model="exportOptions.companyIds"
multiple
filterable
placeholder="搜索并选择企业(不选则导出所有)"
class="w-full"
clearable
:loading="companyLoading"
@focus="loadCompanyOptions"
@visible-change="handleCompanyVisibleChange"
>
<el-option
v-for="company in companyOptions"
:key="company.id"
:label="company.company_name"
:value="company.id"
/>
<div v-if="companyLoading" class="text-center py-2">
<span class="text-gray-500">加载中...</span>
</div>
</el-select>
</div>
<!-- 产品选择 -->
<div v-if="showProductSelect">
<label class="block text-sm font-medium text-gray-700 mb-2">选择产品</label>
<el-select
v-model="exportOptions.productIds"
multiple
filterable
remote
reserve-keyword
placeholder="搜索并选择产品(不选则导出所有)"
class="w-full"
clearable
:remote-method="handleProductSearch"
:loading="productLoading"
@focus="loadProductOptions"
@visible-change="handleProductVisibleChange"
>
<el-option
v-for="product in productOptions"
:key="product.id"
:label="product.name"
:value="product.id"
/>
<div v-if="productLoading" class="text-center py-2">
<span class="text-gray-500">加载中...</span>
</div>
</el-select>
</div>
<!-- 充值类型选择 -->
<div v-if="showRechargeTypeSelect">
<label class="block text-sm font-medium text-gray-700 mb-2">充值类型</label>
<el-select
v-model="exportOptions.rechargeType"
placeholder="选择充值类型(不选则导出所有)"
class="w-full"
clearable
>
<el-option label="全部" value="" />
<el-option label="支付宝" value="alipay" />
<el-option label="转账" value="transfer" />
<el-option label="赠送" value="gift" />
</el-select>
</div>
<!-- 状态选择 -->
<div v-if="showStatusSelect">
<label class="block text-sm font-medium text-gray-700 mb-2">状态</label>
<el-select
v-model="exportOptions.status"
placeholder="选择状态(不选则导出所有)"
class="w-full"
clearable
>
<el-option label="全部" value="" />
<el-option label="待处理" value="pending" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
</div>
<!-- 时间范围 -->
<div v-if="showDateRange">
<label class="block text-sm font-medium text-gray-700 mb-2">时间范围</label>
<el-date-picker
v-model="exportOptions.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-full"
/>
</div>
<!-- 导出格式 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">导出格式</label>
<el-radio-group v-model="exportOptions.format">
<el-radio value="excel">Excel (.xlsx)</el-radio>
<el-radio value="csv">CSV (.csv)</el-radio>
</el-radio-group>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<el-button @click="handleCancel">取消</el-button>
<el-button
type="primary"
:loading="loading"
@click="handleConfirm"
>
确认导出
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { productApi, userApi } from '@/api'
import { reactive, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: '导出数据'
},
loading: {
type: Boolean,
default: false
},
// 控制显示哪些筛选选项
showCompanySelect: {
type: Boolean,
default: true
},
showProductSelect: {
type: Boolean,
default: true
},
showRechargeTypeSelect: {
type: Boolean,
default: false
},
showStatusSelect: {
type: Boolean,
default: false
},
showDateRange: {
type: Boolean,
default: true
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
// 响应式数据
const visible = ref(false)
const exportOptions = reactive({
companyIds: [],
productIds: [],
rechargeType: '',
status: '',
dateRange: [],
format: 'excel'
})
// 企业选项
const companyOptions = ref([])
const companyLoading = ref(false)
// 产品选项
const productOptions = ref([])
const productLoading = ref(false)
const productSearchKeyword = ref('')
// 监听modelValue变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
})
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 企业相关方法
const loadCompanyOptions = async () => {
if (companyLoading.value) return
try {
companyLoading.value = true
const response = await userApi.getUserList({
page: 1,
page_size: 1000,
is_certified: true // 只加载已认证用户
})
companyOptions.value = response.data?.items?.map(user => ({
id: user.id,
company_name: user.enterprise_info?.company_name || user.phone || '未知企业'
})) || []
} catch (error) {
console.error('加载企业选项失败:', error)
} finally {
companyLoading.value = false
}
}
const handleCompanyVisibleChange = (visible) => {
if (visible && companyOptions.value.length === 0) {
loadCompanyOptions()
}
}
// 产品相关方法
const loadProductOptions = async () => {
if (productLoading.value) return
try {
productLoading.value = true
const response = await productApi.getProducts({
page: 1,
page_size: 1000,
name: productSearchKeyword.value
})
productOptions.value = response.data?.items?.map(product => ({
id: product.id,
name: product.name
})) || []
} catch (error) {
console.error('加载产品选项失败:', error)
} finally {
productLoading.value = false
}
}
const handleProductSearch = (keyword) => {
productSearchKeyword.value = keyword
loadProductOptions()
}
const handleProductVisibleChange = (visible) => {
if (visible && productOptions.value.length === 0) {
loadProductOptions()
}
}
// 事件处理
const handleConfirm = () => {
emit('confirm', { ...exportOptions })
}
const handleCancel = () => {
visible.value = false
emit('cancel')
}
// 重置选项
const resetOptions = () => {
exportOptions.companyIds = []
exportOptions.productIds = []
exportOptions.rechargeType = ''
exportOptions.status = ''
exportOptions.dateRange = []
exportOptions.format = 'excel'
}
// 暴露方法给父组件
defineExpose({
resetOptions
})
</script>
<style scoped>
/* 样式保持简洁主要依赖Tailwind CSS */
</style>

View File

@@ -0,0 +1,138 @@
# FileUpload 文件上传组件
一个通用的文件上传组件,支持拖拽上传、文件预览、删除等功能。
## 功能特性
- ✅ 拖拽上传
- ✅ 文件类型验证
- ✅ 文件大小限制
- ✅ 图片预览
- ✅ 文件删除
- ✅ 自定义标题和描述
- ✅ 响应式设计
- ✅ v-model 支持
## 基本用法
```vue
<template>
<FileUpload
v-model="file"
accept="image/jpeg,image/jpg,image/png"
:max-size="4"
@change="handleFileChange"
@remove="handleFileRemove"
/>
</template>
<script setup>
import { ref } from 'vue'
import FileUpload from '@/components/common/FileUpload.vue'
const file = ref(null)
const handleFileChange = (file) => {
console.log('文件已选择:', file)
}
const handleFileRemove = () => {
console.log('文件已删除')
}
</script>
```
## 自定义配置
```vue
<template>
<FileUpload
v-model="file"
accept="image/jpeg,image/jpg,image/png"
:max-size="10"
title="上传头像"
description="支持 JPG/PNG 格式,文件大小不超过 10MB"
:disabled="false"
@change="handleFileChange"
@remove="handleFileRemove"
/>
</template>
```
## 支持的文件类型
### 图片文件
```vue
<FileUpload
v-model="imageFile"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
:max-size="5"
/>
```
### 文档文件
```vue
<FileUpload
v-model="documentFile"
accept="application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
:max-size="20"
title="上传文档"
description="支持 PDF/DOC/DOCX 格式,文件大小不超过 20MB"
/>
```
### 所有文件类型
```vue
<FileUpload
v-model="anyFile"
accept="*/*"
:max-size="50"
title="上传任意文件"
description="支持所有文件类型,文件大小不超过 50MB"
/>
```
## Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| accept | String | 'image/jpeg,image/jpg,image/png' | 接受的文件类型 |
| maxSize | Number | 4 | 文件大小限制MB |
| title | String | '' | 上传区域标题 |
| description | String | '' | 上传区域描述 |
| disabled | Boolean | false | 是否禁用 |
| modelValue | File/null | null | 当前文件v-model |
| previewUrl | String | '' | 文件预览URL |
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:modelValue | file | 文件变化时触发 |
| change | file | 文件选择时触发 |
| remove | - | 文件删除时触发 |
## 样式定制
组件使用 scoped 样式,如需自定义样式,可以通过以下方式:
```vue
<style scoped>
/* 自定义上传组件样式 */
:deep(.file-upload .el-upload-dragger) {
border-color: #your-color;
background: your-background;
}
:deep(.file-preview) {
border-color: #your-color;
}
</style>
```
## 注意事项
1. 组件会自动阻止自动上传,需要手动处理文件上传逻辑
2. 图片文件会自动生成预览,其他文件类型显示文件图标
3. 文件验证失败时会显示错误提示
4. 组件支持响应式设计,在移动端会自动调整布局

View File

@@ -0,0 +1,371 @@
<template>
<div class="file-upload-container">
<!-- 文件预览区域 -->
<div v-if="filePreview" class="file-preview">
<div class="preview-wrapper">
<img
v-if="isImageFile"
:src="filePreview"
:alt="fileName"
class="preview-image"
/>
<div v-else class="file-icon">
<el-icon class="text-blue-500"><DocumentIcon /></el-icon>
</div>
<div class="preview-overlay">
<el-button
type="danger"
size="small"
class="remove-btn"
@click="removeFile"
>
<el-icon><XMarkIcon /></el-icon>
</el-button>
</div>
</div>
<div class="preview-info">
<p class="preview-text">{{ fileName || '文件已上传' }}</p>
<p class="preview-size">点击删除可重新上传</p>
</div>
</div>
<!-- 上传区域 -->
<el-upload
v-else
class="file-upload"
drag
:auto-upload="false"
:on-change="handleFileChange"
:file-list="fileList"
:accept="accept"
:before-upload="beforeUpload"
:disabled="disabled"
>
<div class="upload-content">
<div class="upload-icon">
<el-icon class="text-blue-500"><ArrowUpTrayIcon /></el-icon>
</div>
<div class="upload-text">
<p class="upload-title">{{ title || '点击或拖拽文件到此处上传' }}</p>
<p class="upload-desc">{{ description || `支持 ${acceptText} 格式,文件大小不超过 ${maxSize}MB` }}</p>
</div>
</div>
</el-upload>
</div>
</template>
<script setup>
import { ArrowUpTrayIcon, DocumentIcon, XMarkIcon } from '@heroicons/vue/24/outline'
import { ElMessage } from 'element-plus'
const props = defineProps({
// 文件类型限制
accept: {
type: String,
default: 'image/jpeg,image/jpg,image/png'
},
// 文件大小限制MB
maxSize: {
type: Number,
default: 4
},
// 上传标题
title: {
type: String,
default: ''
},
// 上传描述
description: {
type: String,
default: ''
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 当前文件
modelValue: {
type: [File, null],
default: null
},
// 文件预览URL
previewUrl: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'change', 'remove'])
// 文件列表
const fileList = ref([])
// 文件预览
const filePreview = ref('')
const fileName = ref('')
// 计算属性
const acceptText = computed(() => {
const types = props.accept.split(',')
return types.map(type => {
if (type.startsWith('image/')) {
return type.replace('image/', '').toUpperCase()
}
return type.replace('application/', '').toUpperCase()
}).join('/')
})
const isImageFile = computed(() => {
return props.accept.includes('image/')
})
// 监听props变化
watch(() => props.modelValue, (newFile) => {
if (newFile) {
fileName.value = newFile.name
if (props.previewUrl) {
filePreview.value = props.previewUrl
} else if (isImageFile.value) {
generatePreview(newFile)
}
} else {
filePreview.value = ''
fileName.value = ''
fileList.value = []
}
}, { immediate: true })
watch(() => props.previewUrl, (newUrl) => {
if (newUrl) {
filePreview.value = newUrl
}
}, { immediate: true })
// 生成图片预览
const generatePreview = (file) => {
const reader = new FileReader()
reader.onload = (e) => {
filePreview.value = e.target.result
}
reader.readAsDataURL(file)
}
// 文件上传前验证
const beforeUpload = (file) => {
// 检查文件类型
const acceptedTypes = props.accept.split(',')
const isValidType = acceptedTypes.some(type => {
if (type.includes('*')) {
return true
}
return file.type === type.trim()
})
if (!isValidType) {
ElMessage.error(`只能上传 ${acceptText.value} 格式的文件!`)
return false
}
// 检查文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
return false
}
return false // 阻止自动上传
}
// 处理文件上传
const handleFileChange = (file) => {
if (file.raw) {
emit('update:modelValue', file.raw)
emit('change', file.raw)
fileName.value = file.raw.name
// 生成预览
if (isImageFile.value) {
generatePreview(file.raw)
}
fileList.value = [file]
}
}
// 删除文件
const removeFile = () => {
emit('update:modelValue', null)
emit('remove')
filePreview.value = ''
fileName.value = ''
fileList.value = []
}
</script>
<style scoped>
.file-upload-container {
width: 100%;
}
/* 文件预览 */
.file-preview {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
background: white;
border-radius: 16px;
border: 2px solid #e2e8f0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.preview-wrapper {
position: relative;
border-radius: 12px;
overflow: hidden;
border: 2px solid #e2e8f0;
background: #f8fafc;
width: 200px;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
.file-icon {
font-size: 48px;
color: #3b82f6;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.preview-wrapper:hover .preview-overlay {
opacity: 1;
}
.remove-btn {
background: rgba(255, 255, 255, 0.95);
border: none;
color: #ef4444;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
}
.remove-btn:hover {
background: #ef4444;
color: white;
transform: scale(1.05);
}
.preview-info {
flex: 1;
}
.preview-text {
font-size: 16px;
font-weight: 600;
color: #1e293b;
margin: 0 0 4px 0;
}
.preview-size {
font-size: 14px;
color: #64748b;
margin: 0;
}
/* 上传区域 */
.file-upload {
width: 100%;
}
.file-upload :deep(.el-upload-dragger) {
border-radius: 16px;
border: 2px dashed #cbd5e1;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
transition: all 0.3s ease;
padding: 40px 20px;
}
.file-upload :deep(.el-upload-dragger:hover) {
border-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.15);
}
.file-upload :deep(.el-upload-dragger.is-dragover) {
border-color: #3b82f6;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.upload-icon {
font-size: 48px;
margin-bottom: 8px;
}
.upload-text {
text-align: center;
}
.upload-title {
font-size: 18px;
font-weight: 600;
color: #1e293b;
margin: 0 0 8px 0;
}
.upload-desc {
font-size: 14px;
color: #64748b;
margin: 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.file-preview {
flex-direction: column;
text-align: center;
gap: 16px;
}
.preview-wrapper {
width: 100%;
max-width: 200px;
}
.preview-image {
max-width: 100%;
}
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<div class="filter-item">
<label class="filter-label">{{ label }}</label>
<slot />
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,24 @@
<template>
<div>
<div class="filter-grid">
<slot />
</div>
<div class="filter-actions">
<div class="filter-stats">
<slot name="stats" />
</div>
<div class="filter-buttons">
<slot name="buttons" />
</div>
</div>
</div>
</template>
<script setup>
// 组件逻辑
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="floating-customer-service" @click="openConsultation">
<div class="floating-button">
<ChatBubbleLeftRightIcon class="h-5 w-5" />
<span class="button-text">联系客服</span>
</div>
<!-- 商务洽谈弹窗 -->
</div>
<BusinessConsultationDialog v-model:visible="consultationVisible" />
</template>
<script setup>
import { ChatBubbleLeftRightIcon } from '@heroicons/vue/24/outline'
import { ref } from 'vue'
import BusinessConsultationDialog from './BusinessConsultationDialog.vue'
// 响应式数据
const consultationVisible = ref(false)
// 打开商务洽谈弹窗
const openConsultation = () => {
consultationVisible.value = true
}
</script>
<style scoped>
.floating-customer-service {
position: fixed;
right: 20px;
bottom: 100px;
z-index: 1000;
cursor: pointer;
}
.floating-button {
display: flex;
align-items: center;
background: linear-gradient(135deg, rgba(64, 158, 255, 0.8) 0%, rgba(103, 194, 58, 0.8) 100%);
color: white;
padding: 12px 16px;
border-radius: 50px;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
min-width: 120px;
justify-content: center;
}
.floating-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(64, 158, 255, 0.3);
}
.floating-button:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
}
.floating-button svg {
margin-right: 6px;
}
.button-text {
white-space: nowrap;
}
/* 响应式设计 */
@media (max-width: 768px) {
.floating-customer-service {
right: 16px;
bottom: 80px;
}
.floating-button {
padding: 10px 14px;
min-width: 100px;
font-size: 13px;
}
.floating-button svg {
margin-right: 4px;
}
}
/* 动画效果 */
@keyframes pulse {
0% {
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
}
50% {
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
}
100% {
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
}
}
.floating-button {
animation: pulse 2s infinite;
}
.floating-button:hover {
animation: none;
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.floating-button {
background: linear-gradient(135deg, rgba(64, 158, 255, 0.9) 0%, rgba(103, 194, 58, 0.9) 100%);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
.floating-button:hover {
box-shadow: 0 6px 20px rgba(64, 158, 255, 0.4);
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="list-page-container">
<div class="list-page-card">
<!-- 页面头部 -->
<div class="list-page-header">
<div class="flex justify-between items-start">
<div>
<h1 class="list-page-title">{{ title }}</h1>
<p class="list-page-subtitle">{{ subtitle }}</p>
</div>
<div class="list-page-actions">
<slot name="actions" />
</div>
</div>
</div>
<!-- 筛选区域 -->
<div v-if="$slots.filters" class="list-page-filters">
<slot name="filters" />
</div>
<!-- 表格区域 -->
<div class="list-page-table">
<div class="table-wrapper">
<slot name="table" />
</div>
</div>
<!-- 分页区域 -->
<div v-if="$slots.pagination" class="list-page-pagination">
<slot name="pagination" />
</div>
</div>
<!-- 其他内容 -->
<slot name="extra" />
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -0,0 +1,338 @@
<template>
<div class="markdown-editor">
<div class="editor-toolbar">
<el-button-group>
<el-button
v-for="tool in toolbarItems"
:key="tool.name"
size="small"
@click="insertText(tool.insert)"
:title="tool.title"
>
<el-icon>
<component :is="tool.icon" />
</el-icon>
</el-button>
</el-button-group>
<el-button
size="small"
@click="togglePreview"
:type="showPreview ? 'primary' : ''"
>
{{ showPreview ? '编辑' : '预览' }}
</el-button>
</div>
<div class="editor-content">
<div v-if="!showPreview" class="editor-textarea">
<el-input
v-model="localValue"
type="textarea"
:placeholder="placeholder"
:rows="textareaRows"
resize="none"
@input="handleInput"
class="markdown-textarea"
/>
</div>
<div v-else class="editor-preview">
<div class="preview-content" v-html="renderedContent"></div>
</div>
</div>
</div>
</template>
<script setup>
import {
BoldIcon,
CodeBracketIcon,
DocumentTextIcon,
ItalicIcon,
LinkIcon,
ListBulletIcon,
PhotoIcon,
TableCellsIcon
} from '@heroicons/vue/24/outline'
import { marked } from 'marked'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
placeholder: {
type: String,
default: '请输入内容...'
},
height: {
type: Number,
default: 200
}
})
const emit = defineEmits(['update:modelValue'])
// 响应式数据
const localValue = ref(props.modelValue)
const showPreview = ref(false)
// 计算textarea行数
const textareaRows = computed(() => {
return Math.max(6, Math.floor(props.height / 24))
})
// 工具栏项目
const toolbarItems = [
{
name: 'bold',
title: '粗体',
icon: BoldIcon,
insert: '**粗体文本**'
},
{
name: 'italic',
title: '斜体',
icon: ItalicIcon,
insert: '*斜体文本*'
},
{
name: 'link',
title: '链接',
icon: LinkIcon,
insert: '[链接文本](链接地址)'
},
{
name: 'image',
title: '图片',
icon: PhotoIcon,
insert: '![图片描述](图片地址)'
},
{
name: 'list',
title: '列表',
icon: ListBulletIcon,
insert: '- 列表项1\n- 列表项2\n- 列表项3'
},
{
name: 'table',
title: '表格',
icon: TableCellsIcon,
insert: '| 列1 | 列2 | 列3 |\n|-----|-----|-----|\n| 内容1 | 内容2 | 内容3 |'
},
{
name: 'code',
title: '代码块',
icon: CodeBracketIcon,
insert: '```\n代码内容\n```'
},
{
name: 'document',
title: '标题',
icon: DocumentTextIcon,
insert: '# 标题\n## 二级标题\n### 三级标题'
}
]
// 渲染后的内容
const renderedContent = computed(() => {
if (!localValue.value) return '<p class="text-gray-400">暂无内容</p>'
return marked(localValue.value)
})
// 监听modelValue变化
watch(() => props.modelValue, (newValue) => {
localValue.value = newValue
})
// 处理输入
const handleInput = (value) => {
localValue.value = value
emit('update:modelValue', value)
}
// 插入文本
const insertText = (text) => {
const textarea = document.querySelector('.markdown-textarea textarea')
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = localValue.value.substring(start, end)
let insertText = text
if (selectedText) {
// 如果有选中文本,替换选中内容
insertText = text.replace(/粗体文本|斜体文本|链接文本|图片描述|列表项\d+|内容\d+|代码内容|标题|二级标题|三级标题/g, selectedText)
}
const newValue = localValue.value.substring(0, start) + insertText + localValue.value.substring(end)
localValue.value = newValue
emit('update:modelValue', newValue)
// 设置光标位置
nextTick(() => {
const newCursorPos = start + insertText.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
})
}
// 切换预览模式
const togglePreview = () => {
showPreview.value = !showPreview.value
}
</script>
<style scoped>
.markdown-editor {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: #f5f7fa;
border-bottom: 1px solid #dcdfe6;
}
.editor-content {
min-height: v-bind(height + 'px');
}
.editor-textarea {
padding: 12px;
}
.markdown-textarea :deep(.el-textarea__inner) {
border: none;
resize: none;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.6;
}
.editor-preview {
padding: 12px;
max-height: v-bind(height + 'px');
overflow-y: auto;
}
.preview-content {
font-size: 14px;
line-height: 1.6;
}
.preview-content :deep(h1) {
font-size: 24px;
font-weight: bold;
margin: 16px 0 8px 0;
color: #303133;
}
.preview-content :deep(h2) {
font-size: 20px;
font-weight: bold;
margin: 14px 0 6px 0;
color: #303133;
}
.preview-content :deep(h3) {
font-size: 16px;
font-weight: bold;
margin: 12px 0 4px 0;
color: #303133;
}
.preview-content :deep(p) {
margin: 8px 0;
color: #606266;
}
.preview-content :deep(strong) {
font-weight: bold;
color: #303133;
}
.preview-content :deep(em) {
font-style: italic;
color: #303133;
}
.preview-content :deep(a) {
color: #409eff;
text-decoration: none;
}
.preview-content :deep(a:hover) {
text-decoration: underline;
}
.preview-content :deep(ul), .preview-content :deep(ol) {
margin: 8px 0;
padding-left: 20px;
color: #606266;
}
.preview-content :deep(li) {
margin: 4px 0;
}
.preview-content :deep(code) {
background-color: #f5f7fa;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
color: #e6a23c;
}
.preview-content :deep(pre) {
background-color: #f5f7fa;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
}
.preview-content :deep(pre code) {
background-color: transparent;
padding: 0;
color: #606266;
}
.preview-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
}
.preview-content :deep(th), .preview-content :deep(td) {
border: 1px solid #dcdfe6;
padding: 8px 12px;
text-align: left;
}
.preview-content :deep(th) {
background-color: #f5f7fa;
font-weight: bold;
color: #303133;
}
.preview-content :deep(td) {
color: #606266;
}
.preview-content :deep(blockquote) {
border-left: 4px solid #409eff;
margin: 8px 0;
padding: 8px 12px;
background-color: #f0f9ff;
color: #606266;
}
</style>

View File

@@ -0,0 +1,314 @@
# 通用组件库
本项目包含了一系列可复用的通用组件,用于提高开发效率和保持界面一致性。
## 组件列表
### FileUpload 文件上传组件
一个功能完整的文件上传组件,支持拖拽上传、文件预览、删除等功能。
**特性:**
- ✅ 拖拽上传
- ✅ 文件类型验证
- ✅ 文件大小限制
- ✅ 图片预览
- ✅ 文件删除
- ✅ 自定义标题和描述
- ✅ 响应式设计
- ✅ v-model 支持
**使用示例:**
```vue
<template>
<FileUpload
v-model="file"
accept="image/jpeg,image/jpg,image/png"
:max-size="4"
title="上传图片"
description="支持 JPG/PNG 格式,文件大小不超过 4MB"
@change="handleFileChange"
@remove="handleFileRemove"
/>
</template>
<script setup>
import { ref } from 'vue'
import FileUpload from '@/components/common/FileUpload.vue'
const file = ref(null)
const handleFileChange = (file) => {
console.log('文件已选择:', file)
}
const handleFileRemove = () => {
console.log('文件已删除')
}
</script>
```
**详细文档:** [FileUpload.md](./FileUpload.md)
## 组件开发规范
### 1. 文件命名
- 组件文件使用 PascalCase 命名,如 `FileUpload.vue`
- 文档文件使用 PascalCase + .md 命名,如 `FileUpload.md`
### 2. 组件结构
```vue
<template>
<!-- 组件模板 -->
</template>
<script setup>
// 组件逻辑
</script>
<style scoped>
/* 组件样式 */
</style>
```
### 3. Props 规范
- 使用 `defineProps` 定义 props
- 为每个 prop 添加类型和默认值
- 添加详细的注释说明
### 4. Events 规范
- 使用 `defineEmits` 定义事件
- 事件名使用 camelCase
- 提供有意义的事件参数
### 5. 样式规范
- 使用 scoped 样式
- 遵循 BEM 命名规范
- 支持响应式设计
- 使用 CSS 变量便于主题定制
### 6. 文档规范
- 每个组件都要有详细的使用文档
- 包含基本用法、Props、Events 说明
- 提供完整的代码示例
- 说明组件的特性和注意事项
## 测试
每个通用组件都应该有对应的测试页面,用于验证组件的各种功能和边界情况。
**测试页面位置:** `src/pages/ComponentNameTest.vue`
**测试内容:**
- 基本功能测试
- 各种配置组合测试
- 边界情况测试
- 响应式设计测试
## 使用建议
1. **优先使用通用组件**:在开发新功能时,优先考虑使用现有的通用组件
2. **保持一致性**:使用通用组件可以保持界面风格的一致性
3. **及时反馈**:如果发现通用组件的问题或需要新功能,及时反馈给团队
4. **文档更新**:当组件功能发生变化时,及时更新相关文档
## 贡献指南
1. **创建新组件**:在 `src/components/common/` 目录下创建新组件
2. **编写文档**:为每个新组件编写详细的使用文档
3. **创建测试页面**:在 `src/pages/` 目录下创建对应的测试页面
4. **更新路由**:在 `src/router/index.js` 中添加测试页面的路由
5. **更新本文档**:在组件列表中添加新组件的说明
# 企业认证功能使用说明
## 概述
本功能用于在需要企业认证的页面中显示认证提示并阻止未认证用户调用相关API接口。认证提示现在通过主布局统一管理无需在每个页面中单独添加组件。
## 实现方式
### 1. 主布局统一管理 (`src/layouts/MainLayout.vue`)
认证提示现在在主布局中统一管理,根据当前页面路径自动显示相应的认证提示。
**核心逻辑:**
- 通过 `getCurrentPageCertificationConfig` 获取当前页面的认证配置
- 根据用户认证状态和页面路径决定是否显示提示
- 支持自定义标题和描述信息
### 2. 菜单配置 (`src/constants/menu.js`)
在菜单配置中定义需要认证的页面和相应的提示信息。
**配置方式:**
```javascript
// 在菜单项中添加认证标记
{ name: '余额充值', path: '/finance/wallet', icon: CreditCard, requiresCertification: true }
// 在页面配置中定义提示信息
'/finance/wallet': {
title: '钱包充值',
description: '为了享受完整的充值服务,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
}
```
### 3. 认证提示组件 (`src/components/common/CertificationNotice.vue`)
可复用的认证提示组件,支持自定义标题和描述。
**组件属性:**
- `show`: 控制是否显示提示Boolean
- `title`: 自定义标题String可选
- `description`: 自定义描述String可选
### 4. 认证组合函数 (`src/composables/useCertification.js`)
提供认证相关的状态和方法用于页面中的API调用保护。
**返回值:**
- `isCertified`: 用户是否已认证
- `certificationLoading`: 认证状态检查是否加载中
- `requiresCertification`: 当前页面是否需要认证
- `callProtectedAPI`: 安全调用需要认证的API
- `canCallAPI`: 是否可以调用API
## 使用方法
### 在页面中使用(简化版)
现在页面中只需要关注API调用的保护无需手动添加认证提示组件
1. **导入认证组合函数**
```vue
<script setup>
import { useCertification } from '@/composables/useCertification'
// 获取认证相关状态和方法
const {
isCertified,
requiresCertification,
callProtectedAPI,
canCallAPI
} = useCertification()
</script>
```
2. **修改API调用**
```javascript
// 原来的API调用
const response = await api.getData()
// 修改为
const response = await callProtectedAPI(api.getData)
if (response) {
// 处理成功响应
} else {
// API调用被阻止显示默认数据或提示
}
```
### 添加新的需要认证的页面
1. **在菜单配置中添加认证标记**
```javascript
// 在 src/constants/menu.js 的 userMenuItems 中
{ name: '新功能', path: '/new-feature', icon: NewIcon, requiresCertification: true }
```
2. **在认证路径列表中添加路径**
```javascript
// 在 requiresCertificationPaths 数组中
export const requiresCertificationPaths = [
// ... 现有路径
'/new-feature'
]
```
3. **添加页面配置信息**
```javascript
// 在 getCurrentPageCertificationConfig 函数中添加
'/new-feature': {
title: '新功能',
description: '为了使用新功能,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
}
```
### 需要认证的页面路径
以下页面路径需要企业认证:
- `/finance/wallet` - 钱包充值
- `/finance/recharge-records` - 充值记录
- `/finance/transactions` - 消费记录
- `/apis/usage` - API调用记录
- `/apis/whitelist` - 白名单管理
## 技术特点
### 1. 统一管理
- 认证提示在主布局中统一管理,避免重复代码
- 页面配置集中管理,易于维护和扩展
### 2. 用户体验优化
- 未认证用户仍能看到页面内容,不会被完全阻止访问
- 根据页面功能提供个性化的认证提示信息
- 支持用户选择关闭提示
### 3. 安全性
- API调用级别的保护防止未认证用户调用敏感接口
- 优雅的错误处理,避免暴露系统内部错误信息
### 4. 可维护性
- 模块化设计,易于扩展和维护
- 清晰的配置方式,便于添加新的需要认证的页面
- 统一的认证逻辑,避免重复代码
## 配置说明
### 自定义认证状态检查
`useCertification.js` 中修改 `checkCertificationStatus` 方法:
```javascript
const checkCertificationStatus = async () => {
try {
// 调用实际的API来检查用户认证状态
const { data } = await userApi.getCertificationStatus()
isCertified.value = data.isCertified || false
} catch (error) {
console.error('Failed to check certification status:', error)
isCertified.value = false
} finally {
certificationLoading.value = false
}
}
```
### 自定义提示样式
可以通过修改 `CertificationNotice.vue` 组件来自定义提示的样式和内容。
## 注意事项
1. **API调用保护**:使用 `callProtectedAPI` 包装所有需要认证的API调用
2. **错误处理**在API调用失败时检查 `canCallAPI` 状态,避免显示不必要的错误信息
3. **用户体验**:未认证用户仍能看到页面内容,但无法进行实际操作
4. **性能优化**:认证状态检查只在需要认证的页面进行
## 示例页面
参考以下页面的实现:
- `src/pages/finance/Wallet.vue` - 钱包充值页面
- `src/pages/api/Usage.vue` - API调用记录页面
- `src/pages/api/WhiteList.vue` - 白名单管理页面
## 迁移指南
如果之前已经在页面中添加了 `CertificationNotice` 组件,可以按以下步骤迁移:
1. 从页面模板中移除 `<CertificationNotice>` 组件
2. 从页面脚本中移除 `CertificationNotice` 的导入
3. 移除 `shouldShowCertificationNotice` 的使用
4. 保留 `callProtectedAPI``canCallAPI` 的使用
认证提示现在会自动在主布局中显示,无需手动管理。

View File

@@ -0,0 +1,86 @@
<template>
<div class="rich-text-editor">
<div style="border: 1px solid #e5e7eb; border-radius: 6px;">
<Toolbar
style="border-bottom: 1px solid #e5e7eb"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
style="height: 300px; overflow-y: hidden;"
v-model="valueHtml"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
@onChange="handleChange"
/>
</div>
</div>
</template>
<script setup>
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import '@wangeditor/editor/dist/css/style.css'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
mode: {
type: String,
default: 'default' // 或 simple
},
height: {
type: String,
default: '300px'
},
placeholder: {
type: String,
default: '请输入内容...'
}
})
const emit = defineEmits(['update:modelValue'])
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
// 内容 HTML
const valueHtml = ref('')
// 模拟 ajax 异步获取内容
watch(() => props.modelValue, (val) => {
valueHtml.value = val
}, { immediate: true })
// 工具栏配置
const toolbarConfig = {}
// 编辑器配置
const editorConfig = {
placeholder: props.placeholder
}
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor // 记录 editor 实例,重要!
}
const handleChange = (editor) => {
emit('update:modelValue', editor.getHtml())
}
</script>
<style scoped>
.rich-text-editor {
width: 100%;
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="version-info" style="display: none;">
<!-- 隐藏版本显示只保留后台检查功能 -->
</div>
</template>
<script setup>
import { versionChecker } from '@/utils/version'
import { onMounted, onUnmounted } from 'vue'
// 隐藏版本显示,只保留后台检查功能
// 生命周期
onMounted(() => {
// 启动后台版本检查
versionChecker.startAutoCheck()
})
onUnmounted(() => {
// 停止版本检查
versionChecker.stopAutoCheck()
})
</script>
<style scoped>
.version-info {
display: inline-block;
}
.version-display {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.version-display:hover {
background-color: var(--el-fill-color-light);
}
.version-icon {
font-size: 14px;
color: var(--el-text-color-secondary);
}
.version-text {
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 500;
}
.version-details {
padding: 16px 0;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.version-item:last-child {
border-bottom: none;
}
.version-item .label {
font-weight: 500;
color: var(--el-text-color-primary);
}
.version-item .value {
color: var(--el-text-color-regular);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,91 @@
import { isPageRequiresCertification } from '@/constants/menu'
import CertificationNotice from './CertificationNotice.vue'
export default function withCertificationCheck(WrappedComponent) {
return {
name: `WithCertificationCheck(${WrappedComponent.name || 'Component'})`,
components: {
CertificationNotice,
WrappedComponent
},
data() {
return {
isCertified: false,
loading: true
}
},
computed: {
requiresCertification() {
return isPageRequiresCertification(this.$route.path)
},
shouldShowNotice() {
return this.requiresCertification && !this.isCertified
}
},
async created() {
await this.checkCertificationStatus()
},
methods: {
async checkCertificationStatus() {
try {
// 这里应该调用实际的API来检查用户认证状态
// 暂时使用模拟数据实际项目中需要替换为真实的API调用
const userStore = this.$store?.state?.user
this.isCertified = userStore?.isCertified || false
} catch (error) {
console.error('Failed to check certification status:', error)
this.isCertified = false
} finally {
this.loading = false
}
},
// 重写需要认证的API调用方法
async callProtectedAPI(apiMethod, ...args) {
if (!this.isCertified && this.requiresCertification) {
console.warn('API call blocked: User not certified')
return null
}
try {
return await apiMethod(...args)
} catch (error) {
if (error.response?.status === 403) {
console.warn('API call failed: Access denied - certification required')
return null
}
throw error
}
}
},
render(h) {
// 如果页面不需要认证,直接渲染原组件
if (!this.requiresCertification) {
return h(WrappedComponent, {
props: this.$attrs,
on: this.$listeners,
scopedSlots: this.$scopedSlots
})
}
// 如果需要认证但用户未认证,显示提示和原组件
return h('div', [
h(CertificationNotice, {
props: {
show: this.shouldShowNotice
}
}),
h(WrappedComponent, {
props: {
...this.$attrs,
isCertified: this.isCertified,
callProtectedAPI: this.callProtectedAPI
},
on: this.$listeners,
scopedSlots: this.$scopedSlots
})
])
}
}
}

View File

@@ -0,0 +1,126 @@
<template>
<div class="app-breadcrumb">
<el-breadcrumb separator="/" class="breadcrumb-container">
<el-breadcrumb-item :to="homePath" class="breadcrumb-item">
<el-icon class="breadcrumb-icon">
<House />
</el-icon>
<span class="breadcrumb-text">{{ homeText }}</span>
</el-breadcrumb-item>
<el-breadcrumb-item v-if="$route.meta.title" class="breadcrumb-item">
<span class="breadcrumb-text">{{ $route.meta.title }}</span>
</el-breadcrumb-item>
</el-breadcrumb>
</div>
</template>
<script setup>
import { HomeIcon as House } from '@heroicons/vue/24/outline';
defineProps({
homePath: {
type: String,
default: '/products'
},
homeText: {
type: String,
default: '首页'
}
})
</script>
<style scoped>
.app-breadcrumb {
margin-bottom: 24px;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.breadcrumb-container {
font-size: 14px;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 8px;
color: #606266;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: #409eff;
}
.breadcrumb-item:last-child {
color: #303133;
font-weight: 500;
}
.breadcrumb-icon {
font-size: 16px;
color: #909399;
}
.breadcrumb-text {
font-size: 14px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.app-breadcrumb {
margin-bottom: 16px;
padding: 12px 0;
}
.breadcrumb-container {
font-size: 13px;
}
.breadcrumb-icon {
font-size: 14px;
}
.breadcrumb-text {
font-size: 13px;
}
}
@media (max-width: 480px) {
.app-breadcrumb {
margin-bottom: 12px;
padding: 8px 0;
}
.breadcrumb-container {
font-size: 12px;
}
.breadcrumb-icon {
font-size: 12px;
}
.breadcrumb-text {
font-size: 12px;
}
}
/* Element Plus 面包屑样式覆盖 */
:deep(.el-breadcrumb__item) {
display: flex;
align-items: center;
}
:deep(.el-breadcrumb__inner) {
display: flex;
align-items: center;
gap: 8px;
color: inherit;
font-weight: inherit;
}
:deep(.el-breadcrumb__separator) {
margin: 0 8px;
color: #c0c4cc;
}
</style>

View File

@@ -0,0 +1,327 @@
<template>
<el-header class="app-header">
<div class="header-container">
<!-- 左侧Logo和菜单按钮 -->
<div class="header-left">
<el-button
@click="appStore.toggleSidebar"
:icon="Menu"
circle
text
class="menu-button"
/>
<div class="header-title">
<h1 class="title-text">{{ title }}</h1>
<el-tag v-if="badge" :type="badgeType" size="small" class="badge-tag">
{{ badge }}
</el-tag>
</div>
</div>
<!-- 右侧用户信息和通知 -->
<div class="header-right">
<!-- 通知按钮 -->
<!-- <el-button
@click="showNotifications = !showNotifications"
:icon="Bell"
circle
text
class="notification-button"
>
<el-badge
v-if="appStore.notifications.length > 0"
:value="appStore.notifications.length"
:type="theme === 'admin' ? 'danger' : 'primary'"
class="notification-badge"
/>
</el-button> -->
<!-- 用户下拉菜单 -->
<el-dropdown @command="handleUserCommand" trigger="click" class="user-dropdown">
<div class="user-info">
<el-avatar :size="32" :src="userStore.user?.avatar" class="user-avatar">
{{ userStore.user?.name?.charAt(0) || (theme === 'admin' ? 'A' : 'U') }}
</el-avatar>
<div class="user-details">
<p class="user-name">
{{ userStore.user?.name || (theme === 'admin' ? '管理员' : '用户') }}
</p>
<p class="user-phone">{{ userStore.user?.phone || '' }}</p>
</div>
<el-icon class="dropdown-icon">
<ArrowDown />
</el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon class="dropdown-item-icon">
<User />
</el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon class="dropdown-item-icon">
<Switch />
</el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-header>
</template>
<script setup>
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import {
ChevronDownIcon as ArrowDown,
BellIcon as Bell,
Bars3Icon as Menu,
ArrowRightOnRectangleIcon as Switch,
UserIcon as User
} from '@heroicons/vue/24/outline'
const props = defineProps({
title: {
type: String,
required: true
},
badge: {
type: String,
default: ''
},
badgeType: {
type: String,
default: 'primary'
},
theme: {
type: String,
default: 'user', // 'user' | 'admin'
validator: (value) => ['user', 'admin'].includes(value)
}
})
const emit = defineEmits(['user-command'])
const router = useRouter()
const userStore = useUserStore()
const appStore = useAppStore()
const showNotifications = ref(false)
// 处理用户菜单命令
const handleUserCommand = async (command) => {
// switch (command) {
// case 'profile':
// router.push('/profile')
// break
// case 'settings':
// router.push(props.theme === 'admin' ? '/admin/system' : '/profile/settings')
// break
// case 'switchToUser':
// router.push('/products')
// break
// case 'logout':
// try {
// await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
// confirmButtonText: '确定',
// cancelButtonText: '取消',
// type: 'warning'
// })
// userStore.logout()
// router.push('/auth/login')
// } catch {
// // 用户取消
// }
// break
// }
emit('user-command', command)
}
</script>
<style scoped>
.app-header {
background: #fff;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 0;
height: 60px;
line-height: 60px;
}
.header-container {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 20px;
max-width: 1400px;
margin: 0 auto;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.menu-button {
color: #606266;
font-size: 18px;
}
.menu-button:hover {
color: #409eff;
background-color: #f5f7fa;
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
}
.title-text {
font-size: 18px;
font-weight: 600;
color: #303133;
margin: 0;
line-height: 1;
}
.badge-tag {
margin-left: 8px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.notification-button {
position: relative;
color: #606266;
font-size: 18px;
}
.notification-button:hover {
color: #409eff;
background-color: #f5f7fa;
}
.notification-badge {
position: absolute;
top: -2px;
right: -2px;
}
.user-dropdown {
cursor: pointer;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 6px;
transition: background-color 0.2s;
}
.user-info:hover {
background-color: #f5f7fa;
}
.user-avatar {
flex-shrink: 0;
}
.user-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: #303133;
margin: 0;
line-height: 1;
}
.user-phone {
font-size: 12px;
color: #909399;
margin: 0;
line-height: 1;
}
.dropdown-icon {
color: #c0c4cc;
font-size: 12px;
transition: transform 0.2s;
}
.user-dropdown:hover .dropdown-icon {
transform: rotate(180deg);
}
.dropdown-item-icon {
margin-right: 8px;
font-size: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header-container {
padding: 0 16px;
}
.header-left {
gap: 12px;
}
.title-text {
font-size: 16px;
}
.user-details {
display: none;
}
.user-info {
padding: 6px 8px;
}
}
@media (max-width: 480px) {
.header-container {
padding: 0 12px;
}
.header-left {
gap: 8px;
}
.title-text {
font-size: 14px;
}
.badge-tag {
display: none;
}
.header-right {
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<el-aside class="app-sidebar" :class="sidebarClasses" :width="sidebarWidth">
<!-- 移动端遮罩层 -->
<div v-if="appStore.isMobile && appStore.mobileSidebarOpen" class="mobile-overlay"
@click="appStore.closeMobileSidebar"></div>
<div class="sidebar-container">
<el-menu :default-active="$route.path" :openeds="openeds"
:collapse="appStore.sidebarCollapsed && !appStore.isMobile" class="sidebar-menu" :class="themeClass" router
@select="handleMenuSelect">
<template v-for="group in menuItems" :key="group.group">
<el-sub-menu :index="group.group">
<template #title>
<el-icon class="menu-icon">
<component :is="group.icon" />
</el-icon>
<span class="menu-title">{{ group.group }}</span>
</template>
<el-menu-item v-for="item in group.children" :key="item.path" :index="item.path" class="menu-item">
<el-icon class="menu-icon">
<component :is="item.icon" />
</el-icon>
<template #title>
<span class="menu-title">{{ item.name }}</span>
</template>
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</div>
</el-aside>
</template>
<script setup>
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
const props = defineProps({
menuItems: {
type: Array,
required: true
},
theme: {
type: String,
default: 'user', // 'user' | 'admin'
validator: (value) => ['user', 'admin'].includes(value)
}
})
const appStore = useAppStore()
const userStore = useUserStore()
const router = useRouter()
// 使用ref来控制菜单展开状态
const openeds = ref([])
// 根据用户类型和当前路由动态设置菜单展开状态
const updateOpeneds = () => {
const openedGroups = []
const currentPath = router.currentRoute.value.path
props.menuItems.forEach(item => {
if (item.group === '管理后台') {
// 只有管理员用户才默认展开管理后台菜单
// 或者当前正在访问管理后台页面时也展开
if (userStore.isAdmin || currentPath.startsWith('/admin/')) {
openedGroups.push(item.group)
}
} else {
// 其他菜单组默认展开
openedGroups.push(item.group)
}
})
openeds.value = openedGroups
}
// 监听路由变化和用户状态变化
watch(
[() => router.currentRoute.value.path, () => userStore.isAdmin],
() => {
updateOpeneds()
},
{ immediate: true }
)
// 计算侧边栏宽度
const sidebarWidth = computed(() => {
if (appStore.isMobile) {
return '240px'
}
return appStore.sidebarCollapsed ? '64px' : '240px'
})
// 主题样式类
const themeClass = computed(() => {
return props.theme === 'admin' ? 'admin-theme' : 'user-theme'
})
// 侧边栏样式类
const sidebarClasses = computed(() => {
const classes = []
if (appStore.isMobile) {
classes.push('mobile-sidebar')
if (appStore.mobileSidebarOpen) {
classes.push('sidebar-open')
}
}
return classes
})
// 处理菜单选择
const handleMenuSelect = () => {
// 移动端选择菜单项后自动关闭侧边栏
if (appStore.isMobile) {
appStore.closeMobileSidebar()
}
}
</script>
<style scoped>
.app-sidebar {
background: #fff;
transition: width 0.3s ease;
overflow: hidden;
position: relative;
height: 100%;
}
.sidebar-container {
height: 100%;
display: flex;
flex-direction: column;
position: relative;
z-index: 1001;
}
.sidebar-menu {
border: none;
height: 100%;
flex: 1 1 0%;
}
.sidebar-menu:not(.el-menu--collapse) {
width: 240px;
}
.menu-item {
height: 50px;
line-height: 50px;
margin: 4px 8px;
transition: background-color 0.3s ease, color 0.3s ease;
border: 2px solid transparent;
border-radius: 48px;
}
.menu-item:hover {
background-color: #f5f7fa;
}
.menu-item.is-active {
background-color: #ecf5ff;
color: #409eff;
}
.menu-icon {
font-size: 18px;
margin-right: 12px;
width: 20px;
height: 20px;
}
.menu-title {
font-size: 14px;
font-weight: 500;
}
/* 用户主题样式 */
.menu-item.is-active {
background-color: #ecf5ff !important;
color: #409eff;
}
.menu-item:hover {
background-color: #f0f9ff !important;
}
/* 折叠状态样式 */
.el-menu--collapse .menu-item {
margin: 4px 4px;
}
.el-menu--collapse .menu-icon {
margin-right: 0;
}
/* 移动端样式 */
.mobile-sidebar {
position: fixed;
top: 60px;
left: 0;
z-index: 1000;
height: calc(100vh - 60px);
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.mobile-sidebar.sidebar-open {
transform: translateX(0);
}
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
}
/* 响应式设计 */
@media (max-width: 768px) {
.sidebar-menu:not(.el-menu--collapse) {
width: 240px;
}
}
@media (max-width: 480px) {
.sidebar-menu:not(.el-menu--collapse) {
width: 200px;
}
.menu-item {
height: 44px;
line-height: 44px;
margin: 2px 4px;
}
.menu-icon {
font-size: 16px;
margin-right: 8px;
}
.menu-title {
font-size: 13px;
}
}
/* Element Plus 菜单样式覆盖 */
:deep(.el-menu) {
background-color: transparent;
}
:deep(.el-menu-item) {
background-color: transparent;
}
:deep(.el-menu-item:hover) {
background-color: transparent;
}
:deep(.el-menu-item.is-active) {
background-color: transparent;
}
:deep(.el-menu--collapse .el-menu-item) {
padding: 0 20px;
}
:deep(.el-menu--collapse .el-menu-item .el-icon) {
margin: 0;
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<el-drawer
:model-value="visible"
:title="title"
direction="rtl"
size="400px"
:with-header="true"
@close="handleClose"
@update:model-value="(val) => emit('update:visible', val)"
class="notification-drawer"
>
<div class="notification-container">
<div class="notification-content">
<el-empty
v-if="appStore.notifications.length === 0"
description="暂无通知"
class="empty-notification"
/>
<div v-else class="notification-list">
<div
v-for="notification in appStore.notifications"
:key="notification.id"
class="notification-item"
>
<div class="notification-icon">
<div class="icon-container" :class="themeClass">
<el-icon class="notification-icon-svg">
<Info />
</el-icon>
</div>
</div>
<div class="notification-body">
<h4 class="notification-title">{{ notification.title }}</h4>
<p class="notification-message">{{ notification.message }}</p>
<p class="notification-time">
{{ formatDate(notification.timestamp, 'MM-DD HH:mm') }}
</p>
</div>
<div class="notification-actions">
<el-button
@click="appStore.removeNotification(notification.id)"
:icon="Close"
circle
text
size="small"
class="close-button"
/>
</div>
</div>
</div>
</div>
</div>
</el-drawer>
</template>
<script setup>
import { useAppStore } from '@/stores/app'
import { formatDate } from '@/utils'
import { XMarkIcon as Close, InformationCircleIcon as Info } from '@heroicons/vue/24/outline'
const props = defineProps({
visible: {
type: Boolean,
required: true
},
title: {
type: String,
default: '通知'
},
theme: {
type: String,
default: 'user', // 'user' | 'admin'
validator: (value) => ['user', 'admin'].includes(value)
}
})
const emit = defineEmits(['update:visible'])
const appStore = useAppStore()
// 主题样式类
const themeClass = computed(() => {
return props.theme === 'admin' ? 'admin-theme' : 'user-theme'
})
const handleClose = () => {
emit('update:visible', false)
}
</script>
<style scoped>
.notification-drawer {
--el-drawer-bg-color: #fff;
}
.notification-container {
height: 100%;
display: flex;
flex-direction: column;
}
.notification-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
.empty-notification {
padding: 40px 20px;
}
.notification-list {
padding: 16px;
}
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
margin-bottom: 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
transition: all 0.2s ease;
}
.notification-item:hover {
background: #f1f3f4;
border-color: #dee2e6;
}
.notification-icon {
flex-shrink: 0;
}
.icon-container {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.icon-container.user-theme {
background-color: #e3f2fd;
}
.icon-container.admin-theme {
background-color: #ffebee;
}
.notification-icon-svg {
font-size: 20px;
}
.user-theme .notification-icon-svg {
color: #1976d2;
}
.admin-theme .notification-icon-svg {
color: #d32f2f;
}
.notification-body {
flex: 1;
min-width: 0;
}
.notification-title {
font-size: 14px;
font-weight: 600;
color: #212529;
margin: 0 0 4px 0;
line-height: 1.4;
}
.notification-message {
font-size: 13px;
color: #6c757d;
margin: 0 0 8px 0;
line-height: 1.4;
}
.notification-time {
font-size: 11px;
color: #adb5bd;
margin: 0;
line-height: 1;
}
.notification-actions {
flex-shrink: 0;
}
.close-button {
color: #adb5bd;
transition: color 0.2s;
}
.close-button:hover {
color: #6c757d;
}
/* 响应式设计 */
@media (max-width: 768px) {
.notification-list {
padding: 12px;
}
.notification-item {
padding: 12px;
margin-bottom: 8px;
}
.icon-container {
width: 32px;
height: 32px;
}
.notification-icon-svg {
font-size: 16px;
}
.notification-title {
font-size: 13px;
}
.notification-message {
font-size: 12px;
}
.notification-time {
font-size: 10px;
}
}
@media (max-width: 480px) {
.notification-list {
padding: 8px;
}
.notification-item {
padding: 10px;
margin-bottom: 6px;
gap: 8px;
}
.icon-container {
width: 28px;
height: 28px;
}
.notification-icon-svg {
font-size: 14px;
}
.notification-title {
font-size: 12px;
}
.notification-message {
font-size: 11px;
}
.notification-time {
font-size: 9px;
}
}
/* Element Plus Drawer 样式覆盖 */
:deep(.el-drawer__header) {
padding: 16px 20px;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 0;
}
:deep(.el-drawer__title) {
font-size: 16px;
font-weight: 600;
color: #303133;
}
:deep(.el-drawer__body) {
padding: 0;
height: calc(100% - 60px);
}
/* 滚动条样式 */
.notification-content::-webkit-scrollbar {
width: 6px;
}
.notification-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.notification-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.notification-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View File

@@ -0,0 +1,446 @@
<template>
<div class="product-card">
<!-- 产品头部 -->
<div class="card-header">
<div class="product-title-section">
<h3 class="product-title">{{ product.name }}</h3>
<el-tag
size="small"
class="status-badge"
>
#{{ product.code }}
</el-tag>
</div>
<div class="product-badges">
<el-tag v-if="product.is_package" type="success" size="small" class="package-badge">
组合包
</el-tag>
<el-tag v-if="product.category" type="info" size="small" class="category-badge">
{{ product.category.name }}
</el-tag>
</div>
</div>
<!-- 产品描述 -->
<div class="card-description">
<p class="description-text">{{ product.description || '暂无描述' }}</p>
</div>
<!-- 产品状态 -->
<!-- <div class="card-status">
<el-tag
:type="getStatusType(product.is_enabled)"
size="small"
class="status-badge"
>
{{ getStatusText(product.is_enabled) }}
</el-tag>
</div> -->
<!-- 价格信息 -->
<div class="card-price">
<div class="price-main">
<span class="price-amount">¥{{ formatPrice(product.price) }}</span>
<span v-if="product.unit" class="price-unit">/{{ product.unit }}</span>
</div>
<div v-if="product.original_price && product.original_price > product.price" class="price-original">
<span class="original-amount">¥{{ formatPrice(product.original_price) }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="card-actions">
<el-button
type="primary"
@click="handleViewDetail"
class="action-btn view-btn"
size="small"
>
查看详情
</el-button>
<!-- 已停用的产品 -->
<el-button
v-if="!product.is_enabled"
type="info"
disabled
class="action-btn disabled-btn"
size="small"
>
已停用
</el-button>
<!-- 已订阅的产品 -->
<el-button
v-else-if="isSubscribed"
type="info"
disabled
class="action-btn subscribed-btn"
size="small"
>
已订阅
</el-button>
<!-- 可订阅的产品 -->
<el-button
v-else
type="success"
@click="handleSubscribe"
class="action-btn subscribe-btn"
size="small"
>
订阅
</el-button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
product: {
type: Object,
required: true
},
isSubscribed: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['view-detail', 'subscribe'])
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 获取状态类型
const getStatusType = (isEnabled) => {
return isEnabled ? 'success' : 'danger'
}
// 获取状态文本
const getStatusText = (isEnabled) => {
return isEnabled ? '已启用' : '已禁用'
}
// 查看详情
const handleViewDetail = () => {
emit('view-detail', props.product)
}
// 订阅产品
const handleSubscribe = () => {
emit('subscribe', props.product)
}
</script>
<style scoped>
.product-card {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px;
padding: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.08),
0 2px 8px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(255, 255, 255, 0.05);
}
.product-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.product-card:hover {
background: rgba(255, 255, 255, 0.98);
border-color: rgba(59, 130, 246, 0.4);
box-shadow:
0 6px 20px rgba(0, 0, 0, 0.1),
0 3px 10px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.3),
inset 0 -1px 0 rgba(255, 255, 255, 0.1);
}
.product-card:hover::before {
opacity: 1;
}
/* 卡片头部 */
.card-header {
margin-bottom: 16px;
}
.product-title-section {
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.product-title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin: 0 0 4px 0;
line-height: 1.3;
letter-spacing: -0.025em;
}
.product-code {
font-size: 13px;
color: #64748b;
margin: 0;
font-weight: 500;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.product-badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.package-badge {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #16a34a;
font-weight: 600;
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
}
.category-badge {
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
color: #3b82f6;
font-weight: 600;
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
}
/* 卡片描述 */
.card-description {
margin-bottom: 16px;
flex: 1;
}
.description-text {
color: #64748b;
font-size: 14px;
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* 卡片状态 */
.card-status {
margin-bottom: 16px;
}
.status-badge {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.2);
color: #16a34a;
font-weight: 600;
font-size: 11px;
padding: 4px 8px;
border-radius: 6px;
}
.status-badge.el-tag--danger {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #dc2626;
}
/* 价格信息 */
.card-price {
margin-bottom: 20px;
display: flex;
align-items: baseline;
gap: 12px;
}
.price-main {
display: flex;
align-items: baseline;
gap: 4px;
}
.price-amount {
font-size: 24px;
font-weight: 800;
color: #dc2626;
line-height: 1;
}
.price-unit {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.price-original {
display: flex;
align-items: center;
}
.original-amount {
font-size: 14px;
color: #94a3b8;
text-decoration: line-through;
font-weight: 500;
}
/* 操作按钮 */
.card-actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.action-btn {
flex: 1;
border-radius: 8px;
font-weight: 600;
font-size: 13px;
padding: 8px 16px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
}
.view-btn {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-color: #3b82f6;
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2);
}
.view-btn:hover {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
border-color: #2563eb;
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
}
.subscribe-btn {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-color: #10b981;
color: white;
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2);
}
.subscribe-btn:hover {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
border-color: #059669;
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3);
}
.subscribed-btn {
background: rgba(100, 116, 139, 0.1);
border-color: rgba(100, 116, 139, 0.2);
color: #64748b;
cursor: not-allowed;
}
.subscribed-btn:hover {
background: rgba(100, 116, 139, 0.1);
border-color: rgba(100, 116, 139, 0.2);
color: #64748b;
transform: none;
box-shadow: none;
}
.disabled-btn {
background: rgba(100, 116, 139, 0.1);
border-color: rgba(100, 116, 139, 0.2);
color: #64748b;
cursor: not-allowed;
}
.disabled-btn:hover {
background: rgba(100, 116, 139, 0.1);
border-color: rgba(100, 116, 139, 0.2);
color: #64748b;
transform: none;
box-shadow: none;
}
/* 响应式设计 */
@media (max-width: 768px) {
.product-card {
padding: 20px;
}
.product-title {
font-size: 16px;
}
.price-amount {
font-size: 20px;
}
.card-actions {
flex-direction: column;
}
.action-btn {
width: 100%;
}
}
/* 加载动画 */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.product-card {
animation: fadeInUp 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 悬停效果增强 */
.product-card:hover .product-title {
color: #3b82f6;
}
.product-card:hover .price-amount {
color: #b91c1c;
}
/* 焦点状态 */
.product-card:focus-within {
outline: none;
box-shadow:
0 0 0 3px rgba(59, 130, 246, 0.1),
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
</style>

View File

@@ -0,0 +1,543 @@
<template>
<div class="chart-card">
<!-- 图表头部 -->
<div class="chart-header">
<div class="header-left">
<h3 class="chart-title">{{ title }}</h3>
<p class="chart-subtitle" v-if="subtitle">{{ subtitle }}</p>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<el-button type="text" icon="el-icon-more">
<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="refresh">刷新</el-dropdown-item>
<el-dropdown-item command="export">导出</el-dropdown-item>
<el-dropdown-item command="fullscreen">全屏</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<!-- 图表内容 -->
<div class="chart-content" v-loading="loading">
<!-- 图表容器 -->
<div
ref="chartContainer"
class="chart-container"
:style="{ height: chartHeight + 'px' }"
></div>
<!-- 无数据状态 -->
<div class="no-data" v-if="!loading && (!data || data.length === 0)">
<i class="el-icon-data-line"></i>
<p>暂无数据</p>
</div>
<!-- 图表加载失败 -->
<div class="chart-error" v-if="error">
<i class="el-icon-warning"></i>
<p>{{ error }}</p>
<el-button type="text" @click="retry">重试</el-button>
</div>
</div>
<!-- 图表底部信息 -->
<div class="chart-footer" v-if="footerInfo">
<div class="footer-info">
<span v-for="(info, index) in footerInfo" :key="index" class="info-item">
<span class="info-label">{{ info.label }}:</span>
<span class="info-value">{{ info.value }}</span>
</span>
</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
export default {
name: 'ChartCard',
props: {
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
type: {
type: String,
default: 'line',
validator: value => ['line', 'bar', 'pie', 'scatter', 'gauge', 'funnel'].includes(value)
},
data: {
type: Array,
default: () => []
},
options: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
},
height: {
type: Number,
default: 300
},
footerInfo: {
type: Array,
default: () => []
}
},
data() {
return {
chart: null,
error: null,
chartHeight: this.height
}
},
computed: {
// 合并默认配置和用户配置
chartOptions() {
const defaultOptions = this.getDefaultOptions()
return this.mergeOptions(defaultOptions, this.options)
}
},
watch: {
data: {
handler() {
this.updateChart()
},
deep: true
},
options: {
handler() {
this.updateChart()
},
deep: true
},
loading(newVal) {
if (!newVal && this.chart) {
this.updateChart()
}
}
},
mounted() {
this.initChart()
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
if (this.chart) {
this.chart.dispose()
}
window.removeEventListener('resize', this.handleResize)
},
methods: {
// 初始化图表
initChart() {
if (!this.$refs.chartContainer) return
try {
this.chart = echarts.init(this.$refs.chartContainer)
this.updateChart()
this.error = null
} catch (error) {
console.error('图表初始化失败:', error)
this.error = '图表初始化失败'
}
},
// 更新图表
updateChart() {
if (!this.chart || this.loading) return
try {
const options = this.chartOptions
this.chart.setOption(options, true)
this.error = null
} catch (error) {
console.error('图表更新失败:', error)
this.error = '图表更新失败'
}
},
// 获取默认配置
getDefaultOptions() {
const baseOptions = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'transparent',
textStyle: {
color: '#fff'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
color: ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
}
switch (this.type) {
case 'line':
return {
...baseOptions,
xAxis: {
type: 'category',
boundaryGap: false,
data: this.data.map(item => item.time || item.name || item.x)
},
yAxis: {
type: 'value'
},
series: [{
type: 'line',
data: this.data.map(item => item.value || item.y),
smooth: true,
areaStyle: {
opacity: 0.1
}
}]
}
case 'bar':
return {
...baseOptions,
xAxis: {
type: 'category',
data: this.data.map(item => item.name || item.x)
},
yAxis: {
type: 'value'
},
series: [{
type: 'bar',
data: this.data.map(item => item.value || item.y),
barWidth: '60%'
}]
}
case 'pie':
return {
...baseOptions,
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: this.data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
}
case 'gauge':
return {
...baseOptions,
series: [{
type: 'gauge',
data: this.data,
radius: '80%',
startAngle: 200,
endAngle: -20,
min: 0,
max: 100,
splitNumber: 10,
axisLine: {
lineStyle: {
width: 6
}
},
axisTick: {
distance: -30,
splitNumber: 5,
lineStyle: {
width: 2,
color: '#999'
}
},
axisLabel: {
distance: -20,
color: '#999',
fontSize: 12
},
pointer: {
itemStyle: {
color: 'auto'
}
},
title: {
color: '#999'
},
detail: {
valueAnimation: true,
formatter: '{value}%',
color: 'auto'
}
}]
}
default:
return baseOptions
}
},
// 合并配置
mergeOptions(defaultOptions, userOptions) {
return {
...defaultOptions,
...userOptions,
series: userOptions.series || defaultOptions.series
}
},
// 处理窗口大小变化
handleResize() {
if (this.chart) {
this.chart.resize()
}
},
// 处理下拉菜单命令
handleCommand(command) {
switch (command) {
case 'refresh':
this.$emit('refresh')
break
case 'export':
this.exportChart()
break
case 'fullscreen':
this.toggleFullscreen()
break
}
},
// 导出图表
exportChart() {
if (!this.chart) return
try {
const url = this.chart.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: '#fff'
})
const link = document.createElement('a')
link.download = `${this.title}_${new Date().getTime()}.png`
link.href = url
link.click()
this.$message.success('图表导出成功')
} catch (error) {
console.error('图表导出失败:', error)
this.$message.error('图表导出失败')
}
},
// 切换全屏
toggleFullscreen() {
this.$emit('fullscreen', this.title)
},
// 重试
retry() {
this.error = null
this.initChart()
}
}
}
</script>
<style scoped>
.chart-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.chart-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.header-left {
flex: 1;
}
.chart-title {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.chart-subtitle {
margin: 0;
font-size: 12px;
color: #909399;
}
.header-right {
display: flex;
align-items: center;
}
.chart-content {
position: relative;
padding: 20px;
}
.chart-container {
width: 100%;
min-height: 200px;
}
.no-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #c0c4cc;
}
.no-data i {
font-size: 48px;
margin-bottom: 12px;
}
.no-data p {
margin: 0;
font-size: 14px;
}
.chart-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #f56c6c;
}
.chart-error i {
font-size: 48px;
margin-bottom: 12px;
}
.chart-error p {
margin: 0 0 12px 0;
font-size: 14px;
}
.chart-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
background-color: #fafafa;
}
.footer-info {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.info-item {
font-size: 12px;
color: #606266;
}
.info-label {
font-weight: 500;
margin-right: 4px;
}
.info-value {
color: #909399;
}
/* 响应式设计 */
@media (max-width: 768px) {
.chart-header {
padding: 12px 16px;
}
.chart-title {
font-size: 14px;
}
.chart-content {
padding: 16px;
}
.chart-container {
min-height: 150px;
}
.chart-footer {
padding: 8px 16px;
}
.footer-info {
gap: 8px;
}
}
/* 动画效果 */
.chart-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 加载状态样式 */
.chart-content .el-loading-mask {
background-color: rgba(255, 255, 255, 0.8);
}
.chart-content .el-loading-spinner {
margin-top: -25px;
}
.chart-content .el-loading-spinner .el-icon-loading {
font-size: 24px;
color: #409EFF;
}
</style>

View File

@@ -0,0 +1,408 @@
<template>
<div
class="stat-card"
:class="{ 'clickable': clickable }"
@click="handleClick"
>
<div class="card-content">
<!-- 图标和标题 -->
<div class="card-header">
<div class="icon-wrapper" :style="{ backgroundColor: color + '20' }">
<i :class="icon" :style="{ color: color }"></i>
</div>
<div class="title-wrapper">
<h3 class="card-title">{{ title }}</h3>
<p class="card-subtitle" v-if="subtitle">{{ subtitle }}</p>
</div>
</div>
<!-- 数值和单位 -->
<div class="card-body">
<div class="value-wrapper">
<span class="value" :style="{ color: color }">
{{ formattedValue }}
</span>
<span class="unit" v-if="unit">{{ unit }}</span>
</div>
<!-- 趋势指示器 -->
<div class="trend-wrapper" v-if="trend !== null && trend !== undefined">
<div class="trend-indicator" :class="trendClass">
<i :class="trendIcon"></i>
<span class="trend-text">{{ trendText }}</span>
</div>
</div>
</div>
<!-- 底部信息 -->
<div class="card-footer" v-if="footerText">
<span class="footer-text">{{ footerText }}</span>
</div>
</div>
<!-- 加载状态 -->
<div class="loading-overlay" v-if="loading">
<el-icon class="is-loading">
<Loading />
</el-icon>
</div>
</div>
</template>
<script>
import { Loading } from '@element-plus/icons-vue'
export default {
name: 'StatCard',
components: {
Loading
},
props: {
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
value: {
type: [Number, String],
required: true
},
unit: {
type: String,
default: ''
},
trend: {
type: [Number, String],
default: null
},
icon: {
type: String,
default: 'el-icon-data-line'
},
color: {
type: String,
default: '#409EFF'
},
loading: {
type: Boolean,
default: false
},
clickable: {
type: Boolean,
default: false
},
footerText: {
type: String,
default: ''
},
precision: {
type: Number,
default: 0
}
},
computed: {
// 格式化数值
formattedValue() {
if (typeof this.value === 'number') {
if (this.precision > 0) {
return this.value.toFixed(this.precision)
}
return this.value.toLocaleString()
}
return this.value
},
// 趋势类型
trendClass() {
if (this.trend === null || this.trend === undefined) return ''
const trendValue = parseFloat(this.trend)
if (trendValue > 0) return 'trend-up'
if (trendValue < 0) return 'trend-down'
return 'trend-stable'
},
// 趋势图标
trendIcon() {
if (this.trend === null || this.trend === undefined) return ''
const trendValue = parseFloat(this.trend)
if (trendValue > 0) return 'el-icon-top'
if (trendValue < 0) return 'el-icon-bottom'
return 'el-icon-minus'
},
// 趋势文本
trendText() {
if (this.trend === null || this.trend === undefined) return ''
const trendValue = parseFloat(this.trend)
if (trendValue > 0) return `+${Math.abs(trendValue)}%`
if (trendValue < 0) return `-${Math.abs(trendValue)}%`
return '0%'
}
},
methods: {
handleClick() {
if (this.clickable) {
this.$emit('click', {
title: this.title,
value: this.value,
trend: this.trend
})
}
}
}
}
</script>
<style scoped>
.stat-card {
position: relative;
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
overflow: hidden;
}
.stat-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
border-color: #409EFF;
}
.card-content {
position: relative;
z-index: 1;
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.icon-wrapper {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
transition: all 0.3s ease;
}
.icon-wrapper i {
font-size: 20px;
transition: all 0.3s ease;
}
.title-wrapper {
flex: 1;
}
.card-title {
margin: 0 0 4px 0;
font-size: 14px;
font-weight: 500;
color: #606266;
line-height: 1.4;
}
.card-subtitle {
margin: 0;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.card-body {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 12px;
}
.value-wrapper {
display: flex;
align-items: baseline;
gap: 4px;
}
.value {
font-size: 28px;
font-weight: 600;
line-height: 1;
transition: all 0.3s ease;
}
.unit {
font-size: 14px;
color: #909399;
font-weight: 400;
}
.trend-wrapper {
display: flex;
align-items: center;
}
.trend-indicator {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.trend-up {
background-color: #f0f9ff;
color: #67C23A;
}
.trend-down {
background-color: #fef0f0;
color: #F56C6C;
}
.trend-stable {
background-color: #f4f4f5;
color: #909399;
}
.trend-indicator i {
font-size: 12px;
}
.trend-text {
font-size: 12px;
font-weight: 500;
}
.card-footer {
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.footer-text {
font-size: 12px;
color: #909399;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-overlay .el-icon {
font-size: 24px;
color: #409EFF;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stat-card {
padding: 16px;
}
.card-header {
margin-bottom: 12px;
}
.icon-wrapper {
width: 40px;
height: 40px;
margin-right: 10px;
}
.icon-wrapper i {
font-size: 18px;
}
.card-title {
font-size: 13px;
}
.value {
font-size: 24px;
}
.card-body {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.trend-wrapper {
align-self: flex-end;
}
}
/* 动画效果 */
.stat-card {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 主题变体 */
.stat-card.theme-dark {
background: #2c3e50;
border-color: #34495e;
color: #ecf0f1;
}
.stat-card.theme-dark .card-title {
color: #bdc3c7;
}
.stat-card.theme-dark .card-subtitle {
color: #95a5a6;
}
.stat-card.theme-dark .unit {
color: #95a5a6;
}
.stat-card.theme-dark .card-footer {
border-color: #34495e;
}
.stat-card.theme-dark .footer-text {
color: #95a5a6;
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<div class="statistics-dashboard">
<!-- 仪表板头部 -->
<div class="dashboard-header">
<h2>{{ dashboardName || '统计仪表板' }}</h2>
<div class="header-actions">
<el-button
size="small"
@click="refreshData"
:loading="loading"
>
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button
size="small"
@click="toggleAutoRefresh"
:type="autoRefresh ? 'primary' : 'default'"
>
<el-icon><Timer /></el-icon>
{{ autoRefresh ? '停止自动刷新' : '开启自动刷新' }}
</el-button>
</div>
</div>
<!-- 概览卡片 -->
<el-row :gutter="20" class="overview-cards">
<el-col :span="6" v-for="(card, index) in overviewCards" :key="index">
<StatCard
:title="card.title"
:value="card.value"
:unit="card.unit"
:trend="card.trend"
:icon="card.icon"
:color="card.color"
:loading="loading"
:precision="card.precision"
/>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="20" class="charts-section">
<el-col :span="12" v-for="(chart, index) in charts" :key="index">
<ChartCard
:title="chart.title"
:subtitle="chart.subtitle"
:type="chart.type"
:data="chart.data"
:loading="loading"
:height="chart.height || 300"
@refresh="refreshChartData(index)"
@export="exportChartData(index)"
@fullscreen="fullscreenChart(index)"
/>
</el-col>
</el-row>
<!-- 数据表格 -->
<el-card class="data-table-card" v-if="tableData.length > 0">
<template v-slot:header>
<div class="card-header">
<span>详细数据</span>
<el-button size="small" @click="exportTableData">
<el-icon><Download /></el-icon>
导出数据
</el-button>
</div>
</template>
<el-table
:data="tableData"
v-loading="loading"
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column
v-for="column in tableColumns"
:key="column.prop"
:prop="column.prop"
:label="column.label"
:width="column.width"
:sortable="column.sortable"
>
<template v-slot="scope">
<span v-if="column.type === 'number'">
{{ formatNumber(scope.row[column.prop]) }}
</span>
<span v-else-if="column.type === 'date'">
{{ formatDate(scope.row[column.prop]) }}
</span>
<span v-else>
{{ scope.row[column.prop] }}
</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="pagination.total > 0">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
background
>
</el-pagination>
</div>
</el-card>
<!-- 空状态 -->
<el-empty v-if="!loading && !hasData" description="暂无数据">
<el-button type="primary" @click="refreshData">刷新数据</el-button>
</el-empty>
</div>
</template>
<script>
import { getDashboardData } from '@/api/statistics'
import { Download, Refresh, Timer } from '@element-plus/icons-vue'
import ChartCard from './ChartCard.vue'
import StatCard from './StatCard.vue'
export default {
name: 'StatisticsDashboard',
components: {
ChartCard,
StatCard,
Refresh,
Timer,
Download
},
props: {
dashboardId: {
type: String,
required: true
},
userRole: {
type: String,
default: 'user'
},
period: {
type: String,
default: 'today'
},
customDateRange: {
type: Array,
default: () => []
},
autoRefresh: {
type: Boolean,
default: false
},
refreshInterval: {
type: Number,
default: 300000 // 5分钟
}
},
data() {
return {
loading: false,
dashboardData: null,
dashboardName: '',
overviewCards: [],
charts: [],
tableData: [],
tableColumns: [],
pagination: {
page: 1,
pageSize: 10,
total: 0
},
refreshTimer: null
}
},
computed: {
hasData() {
return this.overviewCards.length > 0 || this.charts.length > 0 || this.tableData.length > 0
}
},
mounted() {
this.loadDashboardData()
if (this.autoRefresh) {
this.startAutoRefresh()
}
},
beforeUnmount() {
this.stopAutoRefresh()
},
watch: {
dashboardId() {
this.loadDashboardData()
},
period() {
this.loadDashboardData()
},
customDateRange() {
this.loadDashboardData()
},
autoRefresh(newVal) {
if (newVal) {
this.startAutoRefresh()
} else {
this.stopAutoRefresh()
}
}
},
methods: {
async loadDashboardData() {
this.loading = true
try {
const params = {
user_role: this.userRole,
period: this.period,
start_date: this.customDateRange[0],
end_date: this.customDateRange[1],
page: this.pagination.page,
limit: this.pagination.pageSize
}
const response = await getDashboardData(this.dashboardId, params)
if (response.success) {
this.dashboardData = response.data
this.processDashboardData()
} else {
this.$message.error(response.message || '获取仪表板数据失败')
}
} catch (error) {
console.error('获取仪表板数据失败:', error)
this.$message.error('获取仪表板数据失败')
} finally {
this.loading = false
}
},
processDashboardData() {
if (!this.dashboardData) return
// 处理仪表板名称
this.dashboardName = this.dashboardData.name || '统计仪表板'
// 处理概览卡片
this.overviewCards = this.dashboardData.overview_cards || []
// 处理图表数据
this.charts = this.dashboardData.charts || []
// 处理表格数据
this.tableData = this.dashboardData.table_data || []
this.tableColumns = this.dashboardData.table_columns || []
// 处理分页信息
if (this.dashboardData.pagination) {
this.pagination = {
...this.pagination,
...this.dashboardData.pagination
}
}
},
async refreshData() {
await this.loadDashboardData()
this.$message.success('数据已刷新')
},
async refreshChartData(chartIndex) {
// 刷新特定图表数据
this.$message.info('图表数据刷新功能开发中')
},
exportChartData(chartIndex) {
// 导出图表数据
this.$message.info('图表数据导出功能开发中')
},
fullscreenChart(chartIndex) {
// 全屏显示图表
this.$message.info('图表全屏功能开发中')
},
exportTableData() {
// 导出表格数据
this.$message.info('表格数据导出功能开发中')
},
toggleAutoRefresh() {
this.$emit('update:autoRefresh', !this.autoRefresh)
},
startAutoRefresh() {
this.stopAutoRefresh()
this.refreshTimer = setInterval(() => {
this.loadDashboardData()
}, this.refreshInterval)
},
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
},
handleSortChange({ prop, order }) {
// 处理表格排序
this.loadDashboardData()
},
handleSizeChange(val) {
this.pagination.pageSize = val
this.loadDashboardData()
},
handleCurrentChange(val) {
this.pagination.page = val
this.loadDashboardData()
},
formatNumber(value) {
if (typeof value !== 'number') return value
return value.toLocaleString()
},
formatDate(value) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN')
}
}
}
</script>
<style scoped>
.statistics-dashboard {
padding: 20px;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.dashboard-header h2 {
margin: 0;
color: #303133;
font-size: 24px;
}
.header-actions {
display: flex;
gap: 8px;
}
.overview-cards {
margin-bottom: 30px;
}
.charts-section {
margin-bottom: 30px;
}
.data-table-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination-wrapper {
margin-top: 20px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,77 @@
import { isPageRequiresCertification } from '@/constants/menu'
import { useUserStore } from '@/stores/user'
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
export function useCertification() {
const route = useRoute()
const userStore = useUserStore()
const certificationLoading = ref(true)
const requiresCertification = computed(() => {
return isPageRequiresCertification(route.path)
})
// 改为 computed自动响应 userStore.isCertified 的变化
const isCertified = computed(() => {
return userStore.isCertified || false
})
const shouldShowCertificationNotice = computed(() => {
return requiresCertification.value && !isCertified.value && !certificationLoading.value
})
const checkCertificationStatus = async () => {
try {
// 这里应该调用实际的API来检查用户认证状态
// 实际项目中需要替换为真实的API调用
// 例如const { data } = await userApi.getCertificationStatus()
// 模拟API调用延迟
await new Promise(resolve => setTimeout(resolve, 500))
} catch (error) {
console.error('Failed to check certification status:', error)
} finally {
certificationLoading.value = false
}
}
const callProtectedAPI = async (apiMethod, ...args) => {
if (!isCertified.value && requiresCertification.value) {
console.warn('API call blocked: User not certified')
return null
}
try {
return await apiMethod(...args)
} catch (error) {
if (error.response?.status === 403) {
console.warn('API call failed: Access denied - certification required')
return null
}
throw error
}
}
const canCallAPI = computed(() => {
return !requiresCertification.value || isCertified.value
})
onMounted(() => {
if (requiresCertification.value) {
checkCertificationStatus()
} else {
certificationLoading.value = false
}
})
return {
isCertified,
certificationLoading,
requiresCertification,
shouldShowCertificationNotice,
checkCertificationStatus,
callProtectedAPI,
canCallAPI
}
}

View File

@@ -0,0 +1,127 @@
// 产品文档默认内容
export const DOCUMENTATION_DEFAULTS = {
// 基础说明默认内容
basicInfo: `## 请求头
| 字段名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| Access-Id | string | 是 | 账号的 Access-Id |
对于业务请求参数
通过加密后得到 Base64 字符串,将其放入到请求体中,字段名为 \`data\`,以此方式进行传参。
\`\`\`json
{
"data": "xxxx(base64)"
}
\`\`\`
对接响应得到的公共参数
\`\`\`json
{
"code": "int",
"message": "string",
"transaction_id": "string", // 流水号
"data": "string"
}
\`\`\`
**data** 字段为加密的数据,需要解密后查看。
## 加密和解密机制
账户获得的密钥(**Access Key**)是一个 16 进制字符串,使用 AES-128 加密算法。
### 加密过程:
- 加密模式:**AES-CBC 模式**。
- 密钥长度:**128 位16 字节)**。
- 填充方式:**PKCS7 填充**。
- **IV初始化向量**IV 长度为 16 字节128 位),每次加密时随机生成。
- 加密后,将 **IV** 和密文拼接在一起进行传输。
- 最后,将拼接了 IV 的密文通过 **Base64 编码**,方便在网络或文件中传输。
### 解密过程:
- 解密时,首先从 Base64 解码后的数据中提取前 16 字节作为 **IV**。
- 然后使用提取的 **IV**,通过 AES-CBC 模式解密剩余部分的密文。
- 解密后去除 **PKCS7 填充**,即可得到原始明文。`,
// 请求参数默认内容
requestParams: `## 请求参数
\`\`\`json
{
"mobile_no": "string",
"id_card": "string",
"name": "string"
}
\`\`\`
| 字段名 | 类型 | 必填 | 描述 |
|--------|------|------|------|
| mobile_no | string | 是 | 手机号 |
| id_card | string | 是 | 身份证号 |
| name | string | 是 | 姓名 |
通过加密后得到 Base64 字符串,将其放入到请求体中,字段名为 \`data\`
\`\`\`json
{
"data": "xxxx(base64)"
}
\`\`\``,
// 返回字段说明默认内容
responseFields: `## 返回字段说明
| 字段名 | 类型 | 说明 |
|--------|------|------|
| data.flag_telefraudpredictstd | string | 1(输出成功),0(未匹配上无输出),98(用户输入信息不足),99(系统异常) |
| data.tfps_level | string | 取值 0-6取值越高风险越大 |`,
// 响应示例默认内容
responseExample: `## 响应示例
### 成功响应
\`\`\`json
{
"data": {
"swift_number": "999333_20181029143459_23453A4E0",
"code": "00",
"flag_telefraudpredictstd": "1",
"tfps_level": "1"
}
}
\`\`\``,
// 错误代码默认内容
errorCodes: `## 错误代码
| code | message |
|------|---------|
| 0 | 业务成功 |
| 1000 | 查询为空 |
| 1001 | 接口异常 |
| 1002 | 参数解密失败 |
| 1003 | 基础参数校验不正确 |
| 1004 | 未经授权的IP |
| 1005 | 缺少Access-Id |
| 1006 | 未经授权的AccessId |
| 1007 | 账户余额不足,无法请求 |
| 1008 | 未开通此产品 |
| 2001 | 业务失败 |`
}
// 获取默认内容的辅助函数
export const getDefaultContent = (type) => {
return DOCUMENTATION_DEFAULTS[type] || ''
}
// 默认内容类型枚举
export const DEFAULT_CONTENT_TYPES = {
BASIC_INFO: 'basicInfo',
REQUEST_PARAMS: 'requestParams',
RESPONSE_FIELDS: 'responseFields',
RESPONSE_EXAMPLE: 'responseExample',
ERROR_CODES: 'errorCodes'
}

80
src/constants/icons.js Normal file
View File

@@ -0,0 +1,80 @@
// 图标映射配置 - Element Plus 到 Heroicons 的映射
import {
ArrowRightOnRectangleIcon,
Bars3Icon,
BellIcon,
CalendarIcon,
CheckCircleIcon,
CheckIcon,
ChevronDownIcon,
ClockIcon,
Cog6ToothIcon,
DocumentCheckIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
EyeIcon,
EyeSlashIcon,
FunnelIcon,
// 基础图标
HomeIcon,
InformationCircleIcon,
LinkIcon,
MagnifyingGlassIcon,
MinusIcon,
PencilIcon,
// 其他常用图标
PlusIcon,
ShieldCheckIcon,
TrashIcon,
UserCircleIcon,
UserIcon,
// 管理相关
UsersIcon,
WalletIcon,
XMarkIcon
} from '@heroicons/vue/24/outline'
// Element Plus 图标到 Heroicons 的映射
export const iconMap = {
// 基础导航图标
House: HomeIcon,
User: UserIcon,
Setting: Cog6ToothIcon,
Shield: ShieldCheckIcon,
Wallet: WalletIcon,
Link: LinkIcon,
Bell: BellIcon,
Menu: Bars3Icon,
ArrowDown: ChevronDownIcon,
Close: XMarkIcon,
Info: InformationCircleIcon,
UserFilled: UserCircleIcon,
Switch: ArrowRightOnRectangleIcon,
// 管理相关图标
UserFilled: UsersIcon,
DocumentCheck: DocumentCheckIcon,
// 其他常用图标
Plus: PlusIcon,
Minus: MinusIcon,
Edit: PencilIcon,
Delete: TrashIcon,
View: EyeIcon,
Hide: EyeSlashIcon,
Search: MagnifyingGlassIcon,
Filter: FunnelIcon,
Calendar: CalendarIcon,
Clock: ClockIcon,
Check: CheckIcon,
Warning: ExclamationTriangleIcon,
Error: ExclamationCircleIcon,
Success: CheckCircleIcon
}
// 导出所有 Heroicons 图标供直接使用
export {
ArrowRightOnRectangleIcon, Bars3Icon, BellIcon, CalendarIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, ClockIcon, Cog6ToothIcon, DocumentCheckIcon, ExclamationCircleIcon, ExclamationTriangleIcon, EyeIcon,
EyeSlashIcon, FunnelIcon, HomeIcon, InformationCircleIcon, LinkIcon, MagnifyingGlassIcon, MinusIcon,
PencilIcon, PlusIcon, ShieldCheckIcon, TrashIcon, UserCircleIcon, UserIcon, UsersIcon, WalletIcon, XMarkIcon
}

225
src/constants/index.js Normal file
View File

@@ -0,0 +1,225 @@
// 应用常量
export const APP_NAME = '天远数据控制台'
export const APP_VERSION = '1.0.0'
// API相关常量
export const API_BASE_URL = '/api/v1'
export const API_TIMEOUT = 10000
// 认证相关常量
export const TOKEN_KEY = 'token'
export const REFRESH_TOKEN_KEY = 'refresh_token'
// 用户角色
export const USER_ROLES = {
USER: 'user',
ADMIN: 'admin',
SUPER_ADMIN: 'super_admin'
}
// 认证状态
export const CERTIFICATION_STATUS = {
PENDING: 'pending',
SUBMITTED: 'submitted',
REVIEWING: 'reviewing',
APPROVED: 'approved',
REJECTED: 'rejected'
}
// 认证状态中文映射
export const CERTIFICATION_STATUS_TEXT = {
[CERTIFICATION_STATUS.PENDING]: '待提交',
[CERTIFICATION_STATUS.SUBMITTED]: '已提交',
[CERTIFICATION_STATUS.REVIEWING]: '审核中',
[CERTIFICATION_STATUS.APPROVED]: '已通过',
[CERTIFICATION_STATUS.REJECTED]: '已拒绝'
}
// 交易类型
export const TRANSACTION_TYPES = {
RECHARGE: 'recharge',
WITHDRAW: 'withdraw',
CONSUME: 'consume',
REFUND: 'refund'
}
// 交易状态
export const TRANSACTION_STATUS = {
PENDING: 'pending',
PROCESSING: 'processing',
SUCCESS: 'success',
FAILED: 'failed',
CANCELLED: 'cancelled'
}
// 交易状态中文映射
export const TRANSACTION_STATUS_TEXT = {
[TRANSACTION_STATUS.PENDING]: '待处理',
[TRANSACTION_STATUS.PROCESSING]: '处理中',
[TRANSACTION_STATUS.SUCCESS]: '成功',
[TRANSACTION_STATUS.FAILED]: '失败',
[TRANSACTION_STATUS.CANCELLED]: '已取消'
}
// 验证码场景
export const SMS_SCENES = {
REGISTER: 'register',
LOGIN: 'login',
CHANGE_PASSWORD: 'change_password',
RESET_PASSWORD: 'reset_password',
BIND: 'bind',
UNBIND: 'unbind'
}
// 验证码场景中文映射
export const SMS_SCENES_TEXT = {
[SMS_SCENES.REGISTER]: '注册',
[SMS_SCENES.LOGIN]: '登录',
[SMS_SCENES.CHANGE_PASSWORD]: '修改密码',
[SMS_SCENES.RESET_PASSWORD]: '重置密码',
[SMS_SCENES.BIND]: '绑定',
[SMS_SCENES.UNBIND]: '解绑'
}
// 文件上传相关
export const UPLOAD_CONFIG = {
MAX_SIZE: 10 * 1024 * 1024, // 10MB
ALLOWED_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'],
ALLOWED_EXTENSIONS: ['.jpg', '.jpeg', '.png', '.gif', '.pdf']
}
// 分页配置
export const PAGINATION_CONFIG = {
DEFAULT_PAGE_SIZE: 10,
PAGE_SIZE_OPTIONS: [10, 20, 50, 100]
}
// 主题配置
export const THEME_CONFIG = {
LIGHT: 'light',
DARK: 'dark'
}
// 语言配置
export const LANGUAGE_CONFIG = {
ZH_CN: 'zh-CN',
EN_US: 'en-US'
}
// 路由配置
export const ROUTE_CONFIG = {
AUTH: {
LOGIN: '/auth/login',
REGISTER: '/auth/register',
RESET_PASSWORD: '/auth/reset'
},
DASHBOARD: '/products',
CERTIFICATION: '/certification',
FINANCE: '/finance',
API: '/api',
PROFILE: '/profile',
ADMIN: '/admin'
}
// 权限配置
export const PERMISSIONS = {
// 用户权限
USER_READ: 'user:read',
USER_WRITE: 'user:write',
USER_DELETE: 'user:delete',
// 认证权限
CERTIFICATION_READ: 'certification:read',
CERTIFICATION_WRITE: 'certification:write',
CERTIFICATION_REVIEW: 'certification:review',
// 财务权限
FINANCE_READ: 'finance:read',
FINANCE_WRITE: 'finance:write',
FINANCE_APPROVE: 'finance:approve',
// API权限
API_READ: 'api:read',
API_WRITE: 'api:write',
API_DELETE: 'api:delete',
// 系统权限
SYSTEM_READ: 'system:read',
SYSTEM_WRITE: 'system:write',
SYSTEM_ADMIN: 'system:admin'
}
// 错误码配置
export const ERROR_CODES = {
// 通用错误
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
NETWORK_ERROR: 'NETWORK_ERROR',
TIMEOUT_ERROR: 'TIMEOUT_ERROR',
// 认证错误
UNAUTHORIZED: 'UNAUTHORIZED',
FORBIDDEN: 'FORBIDDEN',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
// 业务错误
VALIDATION_ERROR: 'VALIDATION_ERROR',
RESOURCE_NOT_FOUND: 'RESOURCE_NOT_FOUND',
RESOURCE_ALREADY_EXISTS: 'RESOURCE_ALREADY_EXISTS',
OPERATION_FAILED: 'OPERATION_FAILED',
// 用户相关错误
USER_NOT_FOUND: 'USER_NOT_FOUND',
USER_ALREADY_EXISTS: 'USER_ALREADY_EXISTS',
INVALID_PHONE: 'INVALID_PHONE',
INVALID_CODE: 'INVALID_CODE',
CODE_EXPIRED: 'CODE_EXPIRED',
// 认证相关错误
CERTIFICATION_NOT_FOUND: 'CERTIFICATION_NOT_FOUND',
CERTIFICATION_ALREADY_EXISTS: 'CERTIFICATION_ALREADY_EXISTS',
CERTIFICATION_IN_PROGRESS: 'CERTIFICATION_IN_PROGRESS',
// 财务相关错误
INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
TRANSACTION_FAILED: 'TRANSACTION_FAILED',
WALLET_NOT_FOUND: 'WALLET_NOT_FOUND'
}
// 本地存储键名
export const STORAGE_KEYS = {
TOKEN: 'token',
USER_INFO: 'user_info',
THEME: 'theme',
LANGUAGE: 'language',
SIDEBAR_COLLAPSED: 'sidebar_collapsed',
NOTIFICATIONS: 'notifications'
}
// 正则表达式
export const REGEX = {
PHONE: /^1[3-9]\d{9}$/,
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
ID_CARD: /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/,
PASSWORD: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/,
VERIFY_CODE: /^\d{6}$/
}
// 时间格式
export const DATE_FORMATS = {
DATE: 'YYYY-MM-DD',
DATETIME: 'YYYY-MM-DD HH:mm:ss',
TIME: 'HH:mm:ss',
MONTH: 'YYYY-MM',
YEAR: 'YYYY'
}
// 响应式断点
export const BREAKPOINTS = {
XS: 480,
SM: 768,
MD: 1024,
LG: 1280,
XL: 1440,
XXL: 1920
}

179
src/constants/menu.js Normal file
View File

@@ -0,0 +1,179 @@
import {
ChartBarIcon as ChartBar,
ChartPieIcon as ChartPie,
ClipboardDocumentListIcon as Clipboard,
CreditCardIcon as CreditCard,
CubeIcon as Cube,
DocumentTextIcon as DocumentText,
PresentationChartLineIcon as PresentationChartLine,
Cog6ToothIcon as Setting,
ShieldCheckIcon as ShieldCheck,
ShoppingCartIcon as ShoppingCart,
TagIcon as Tag,
UserIcon as User,
UserGroupIcon as Users,
WalletIcon as Wallet
} from '@heroicons/vue/24/outline'
// 用户菜单配置(分组结构)
export const userMenuItems = [
{
group: '数据中心',
icon: ChartBar,
children: [
{ name: '仪表盘', path: '/dashboard', icon: PresentationChartLine },
{ name: '数据大厅', path: '/products', icon: Cube },
{ name: '我的订阅', path: '/subscriptions', icon: ShoppingCart }
]
},
{
group: '账户中心',
icon: User,
children: [
{ name: '账户中心', path: '/profile', icon: User },
{ name: '企业入驻', path: '/profile/certification', icon: ShieldCheck }
]
},
{
group: '财务管理',
icon: Wallet,
children: [
{ name: '余额充值', path: '/finance/wallet', icon: CreditCard, requiresCertification: true },
{ name: '充值记录', path: '/finance/recharge-records', icon: CreditCard, requiresCertification: true },
{ name: '消费记录', path: '/finance/transactions', icon: Clipboard, requiresCertification: true },
{ name: '发票申请', path: '/finance/invoice', icon: Wallet, requiresCertification: true }
]
},
{
group: '开发者中心',
icon: Setting,
children: [
// { name: 'API管理', path: '/api/management', icon: Key },
{ name: '在线调试', path: '/apis/debugger', icon: Clipboard, requiresCertification: true },
{ name: '调用记录', path: '/apis/usage', icon: Clipboard, requiresCertification: true },
{ name: '白名单管理', path: '/apis/whitelist', icon: ShieldCheck, requiresCertification: true }
]
}
]
// 管理员菜单配置
export const adminMenuItems = [
{
name: '产品管理',
path: '/admin/products',
icon: Cube
},
{
name: '分类管理',
path: '/admin/categories',
icon: Tag
},
{
name: '订阅管理',
path: '/admin/subscriptions',
icon: ShoppingCart
},
{
name: '用户管理',
path: '/admin/users',
icon: Users
},
{
name: '文章管理',
path: '/admin/articles',
icon: DocumentText
},
{
name: '系统统计',
path: '/admin/statistics',
icon: ChartPie
}
]
// 新增:根据用户类型动态生成菜单
export const getMenuItems = (userType = 'user') => {
if (userType === 'admin') {
return adminMenuItems
}
return userMenuItems
}
// 新增:获取用户可访问的菜单项(包含管理员菜单)
export const getUserAccessibleMenuItems = (userType = 'user') => {
const baseMenuItems = [...userMenuItems]
// 如果是管理员,添加管理员菜单组
if (userType === 'admin') {
baseMenuItems.push({
group: '管理后台',
icon: Setting,
children: [
{ name: '系统统计', path: '/admin/statistics', icon: ChartBar },
{ name: '产品管理', path: '/admin/products', icon: Cube },
{ name: '用户管理', path: '/admin/users', icon: Users },
{ name: '分类管理', path: '/admin/categories', icon: Tag },
{ name: '订阅管理', path: '/admin/subscriptions', icon: ShoppingCart },
{ name: '文章管理', path: '/admin/articles', icon: DocumentText },
{ name: '调用记录', path: '/admin/usage', icon: Clipboard },
{ name: '消费记录', path: '/admin/transactions', icon: Clipboard },
{ name: '充值记录', path: '/admin/recharge-records', icon: CreditCard },
{ name: '发票管理', path: '/admin/invoices', icon: Wallet }
]
})
}
return baseMenuItems
}
// 需要企业认证的页面路径列表
export const requiresCertificationPaths = [
'/finance/wallet',
'/finance/recharge-records',
'/finance/transactions',
'/apis/usage',
'/apis/whitelist'
]
// 检查页面是否需要企业认证
export const isPageRequiresCertification = (path) => {
return requiresCertificationPaths.includes(path)
}
// 获取当前页面的认证配置
export const getCurrentPageCertificationConfig = (path) => {
// 检查是否在需要认证的路径列表中
const requiresCert = isPageRequiresCertification(path)
if (!requiresCert) {
return null
}
// 根据路径获取页面信息
const pageConfig = {
'/finance/wallet': {
title: '钱包充值',
description: '为了享受完整的充值服务,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
},
'/finance/recharge-records': {
title: '充值记录',
description: '为了查看完整的充值记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
},
'/finance/transactions': {
title: '消费记录',
description: '为了查看完整的消费记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
},
'/apis/usage': {
title: 'API调用记录',
description: '为了查看完整的API调用记录请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度'
},
'/apis/whitelist': {
title: '白名单管理',
description: '为了管理API访问白名单请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度'
}
}
return {
requiresCertification: true,
...pageConfig[path]
}
}

View File

@@ -0,0 +1,62 @@
<template>
<div class="min-h-screen w-full flex items-center justify-center bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 relative">
<div class="relative w-full max-w-[480px] auth-card px-8 py-10 flex flex-col justify-center min-h-[480px] sm:min-h-[400px] mx-4">
<!-- Logo与标题 -->
<div class="flex flex-col items-center mb-8">
<div class="w-16 h-16 flex items-center justify-center bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl mb-4 shadow-lg">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h1 class="auth-title mb-1">天远数据控制台</h1>
</div>
<!-- 内容区 -->
<div class="flex-1 flex flex-col justify-center">
<router-view />
</div>
<!-- 底部版权 -->
<div class="text-center text-xs text-gray-400 mt-8 pt-6 border-t border-gray-100">
&copy; 2024 天远数据控制台. All rights reserved.
</div>
</div>
</div>
</template>
<script setup>
// 认证布局组件
</script>
<style scoped>
/* 性能优化:减少动画效果 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 性能优化:低端设备降级 */
@media (max-width: 640px) {
.min-h-\[480px\] {
min-height: 320px !important;
}
.px-8 {
padding-left: 1rem !important;
padding-right: 1rem !important;
}
.mx-4 {
margin-left: 0.5rem !important;
margin-right: 0.5rem !important;
}
}
/* 性能优化:简化背景渐变 */
@media (max-width: 768px), (max-device-pixel-ratio: 1) {
.bg-gradient-to-br {
background: #f8fafc !important;
}
}
</style>

219
src/layouts/MainLayout.vue Normal file
View File

@@ -0,0 +1,219 @@
<template>
<el-container class="main-container">
<!-- 简化的页面背景 -->
<div class="page-background"></div>
<!-- 企业认证横幅 - 在header上方 -->
<CertificationBanner />
<el-header class="el-header">
<AppHeader
:title="headerTitle"
:badge="headerBadge"
:badge-type="headerBadgeType"
:theme="headerTheme"
@user-command="handleUserCommand"
/>
</el-header>
<el-container>
<el-aside width="240px">
<AppSidebar :menu-items="currentMenuItems" :theme="sidebarTheme" />
</el-aside>
<el-main>
<div class="content-wrapper">
<!-- 企业认证提示 - 根据当前页面路径显示 -->
<CertificationNotice
v-if="shouldShowCertificationNotice"
:show="true"
:title="certificationConfig?.title"
:description="certificationConfig?.description"
/>
<router-view />
</div>
</el-main>
</el-container>
<NotificationPanel
v-model:visible="showNotifications"
:title="notificationTitle"
:theme="notificationTheme"
/>
<!-- 右侧浮动联系客服 -->
<FloatingCustomerService />
</el-container>
</template>
<script setup>
import CertificationNotice from '@/components/common/CertificationNotice.vue'
import FloatingCustomerService from '@/components/common/FloatingCustomerService.vue'
import AppHeader from '@/components/layout/AppHeader.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import NotificationPanel from '@/components/layout/NotificationPanel.vue'
import { getCurrentPageCertificationConfig, getUserAccessibleMenuItems } from '@/constants/menu'
import { useUserStore } from '@/stores/user'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const showNotifications = ref(false)
// 性能优化:检测设备性能
const isLowPerformanceDevice = computed(() => {
// 检测硬件并发数、内存等
const hardwareConcurrency = navigator.hardwareConcurrency || 4
const memory = navigator.deviceMemory || 4
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
return hardwareConcurrency <= 4 || memory <= 4 || isMobile
})
// 根据用户类型计算布局属性
const isAdmin = computed(() => userStore.isAdmin)
const headerTitle = computed(() => {
return isAdmin.value ? '天远数据控制台' : '天远数据控制台'
})
const headerBadge = computed(() => {
return isAdmin.value ? '管理员模式' : null
})
const headerBadgeType = computed(() => {
return isAdmin.value ? 'danger' : null
})
const headerTheme = computed(() => {
return isAdmin.value ? 'admin' : 'user'
})
const sidebarTheme = computed(() => {
return isAdmin.value ? 'admin' : 'user'
})
const notificationTitle = computed(() => {
return isAdmin.value ? '系统通知' : '通知'
})
const notificationTheme = computed(() => {
return isAdmin.value ? 'admin' : 'user'
})
// 动态菜单项
const currentMenuItems = computed(() => {
return getUserAccessibleMenuItems(userStore.userType, userStore.isCertified)
})
// 认证相关逻辑
const currentRoute = computed(() => router.currentRoute.value)
const certificationConfig = computed(() => {
return getCurrentPageCertificationConfig(currentRoute.value.path)
})
const shouldShowCertificationNotice = computed(() => {
console.log(certificationConfig.value, userStore.isCertified)
// 如果页面不需要认证,不显示提示
if (!certificationConfig.value) {
return false
}
// 如果用户已认证,不显示提示
if (userStore.isCertified) {
return false
}
// 如果是管理员页面,不显示提示
// if (isAdmin.value) {
// return false
// }
return true
})
// 处理用户菜单命令
const handleUserCommand = async (command) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'settings':
if (isAdmin.value) {
router.push('/admin/system')
} else {
router.push('/profile/settings')
}
break
case 'switchToUser':
if (isAdmin.value) {
router.push('/products')
}
break
case 'logout':
try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
userStore.logout()
router.push('/auth/login')
} catch {
// 用户取消
}
break
}
}
</script>
<style scoped>
.page-background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -1;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 50%, #f8fafc 100%);
}
.main-container {
height: 100vh;
background: transparent;
}
.el-header {
padding: 0;
}
.content-wrapper {
padding: 16px;
}
.el-main {
background: transparent;
padding: 0;
}
/* 性能优化:为低端设备提供简化样式 */
@media (max-width: 768px) {
.content-wrapper {
padding: 0 12px 12px 12px;
}
}
/* 性能优化:减少动画效果 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 性能优化:低端设备降级 */
@media (max-width: 768px), (max-device-pixel-ratio: 1) {
.page-background {
background: #f8fafc;
}
}
</style>

53
src/main.js Normal file
View File

@@ -0,0 +1,53 @@
import './assets/main.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
// 导入全局样式
import './assets/styles/index.css'
// 导入渲染性能优化工具
import { initRenderOptimizations } from './utils/performance.js'
const app = createApp(App)
// 初始化渲染性能优化
initRenderOptimizations()
// 全局错误捕获
app.config.errorHandler = (err, vm, info) => {
console.error('Vue错误:', err)
console.error('错误信息:', info)
console.error('组件:', vm)
console.error('错误堆栈:', err.stack)
}
// 全局未捕获异常处理
window.addEventListener('error', (event) => {
console.error('全局错误:', event.error)
console.error('错误消息:', event.message)
console.error('错误文件:', event.filename)
console.error('错误行号:', event.lineno)
console.error('错误列号:', event.colno)
console.error('错误堆栈:', event.error?.stack)
})
// Promise未捕获异常处理
window.addEventListener('unhandledrejection', (event) => {
console.error('Promise错误:', event.reason)
console.error('Promise堆栈:', event.reason?.stack)
})
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn,
})
app.mount('#app')

View File

@@ -0,0 +1,73 @@
import CertificationNotice from '@/components/common/CertificationNotice.vue'
import { isPageRequiresCertification } from '@/constants/menu'
export default {
components: {
CertificationNotice
},
data() {
return {
isCertified: false,
certificationLoading: true
}
},
computed: {
requiresCertification() {
return isPageRequiresCertification(this.$route.path)
},
shouldShowCertificationNotice() {
return this.requiresCertification && !this.isCertified && !this.certificationLoading
}
},
async created() {
if (this.requiresCertification) {
await this.checkCertificationStatus()
}
},
methods: {
async checkCertificationStatus() {
try {
// 这里应该调用实际的API来检查用户认证状态
// 实际项目中需要替换为真实的API调用
const userStore = this.$store?.state?.user
this.isCertified = userStore?.isCertified || false
// 模拟API调用延迟
await new Promise(resolve => setTimeout(resolve, 500))
} catch (error) {
console.error('Failed to check certification status:', error)
this.isCertified = false
} finally {
this.certificationLoading = false
}
},
// 安全调用需要认证的API
async callProtectedAPI(apiMethod, ...args) {
if (!this.isCertified && this.requiresCertification) {
console.warn('API call blocked: User not certified')
return null
}
try {
return await apiMethod(...args)
} catch (error) {
if (error.response?.status === 403) {
console.warn('API call failed: Access denied - certification required')
return null
}
throw error
}
},
// 检查是否可以进行API调用
canCallAPI() {
return !this.requiresCertification || this.isCertified
}
}
}

View File

@@ -0,0 +1,250 @@
<template>
<div class="file-upload-test">
<el-card class="test-card">
<template #header>
<div class="card-header">
<h2>文件上传组件测试</h2>
<p>测试通用文件上传组件的各种功能</p>
</div>
</template>
<div class="test-content">
<!-- 图片上传测试 -->
<div class="test-section">
<h3>图片上传</h3>
<FileUpload
v-model="imageFile"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
:max-size="5"
title="上传图片"
description="支持 JPG/PNG/GIF/WEBP 格式,文件大小不超过 5MB"
@change="handleImageChange"
@remove="handleImageRemove"
/>
<div class="file-info" v-if="imageFile">
<p><strong>文件名:</strong> {{ imageFile.name }}</p>
<p><strong>文件大小:</strong> {{ (imageFile.size / 1024 / 1024).toFixed(2) }} MB</p>
<p><strong>文件类型:</strong> {{ imageFile.type }}</p>
</div>
</div>
<!-- 文档上传测试 -->
<div class="test-section">
<h3>文档上传</h3>
<FileUpload
v-model="documentFile"
accept="application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
:max-size="20"
title="上传文档"
description="支持 PDF/DOC/DOCX 格式,文件大小不超过 20MB"
@change="handleDocumentChange"
@remove="handleDocumentRemove"
/>
<div class="file-info" v-if="documentFile">
<p><strong>文件名:</strong> {{ documentFile.name }}</p>
<p><strong>文件大小:</strong> {{ (documentFile.size / 1024 / 1024).toFixed(2) }} MB</p>
<p><strong>文件类型:</strong> {{ documentFile.type }}</p>
</div>
</div>
<!-- 任意文件上传测试 -->
<div class="test-section">
<h3>任意文件上传</h3>
<FileUpload
v-model="anyFile"
accept="*/*"
:max-size="50"
title="上传任意文件"
description="支持所有文件类型,文件大小不超过 50MB"
@change="handleAnyFileChange"
@remove="handleAnyFileRemove"
/>
<div class="file-info" v-if="anyFile">
<p><strong>文件名:</strong> {{ anyFile.name }}</p>
<p><strong>文件大小:</strong> {{ (anyFile.size / 1024 / 1024).toFixed(2) }} MB</p>
<p><strong>文件类型:</strong> {{ anyFile.type }}</p>
</div>
</div>
<!-- 禁用状态测试 -->
<div class="test-section">
<h3>禁用状态</h3>
<FileUpload
v-model="disabledFile"
accept="image/jpeg,image/jpg,image/png"
:max-size="4"
title="禁用状态"
description="此上传组件已被禁用"
:disabled="true"
/>
</div>
<!-- 操作按钮 -->
<div class="test-actions">
<el-button type="primary" @click="clearAllFiles">清空所有文件</el-button>
<el-button type="success" @click="showFileInfo">显示文件信息</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import FileUpload from '@/components/common/FileUpload.vue'
import { ElMessage } from 'element-plus'
// 文件状态
const imageFile = ref(null)
const documentFile = ref(null)
const anyFile = ref(null)
const disabledFile = ref(null)
// 处理图片文件变化
const handleImageChange = (file) => {
ElMessage.success('图片文件已选择')
console.log('图片文件:', file)
}
// 处理图片文件删除
const handleImageRemove = () => {
ElMessage.info('图片文件已删除')
}
// 处理文档文件变化
const handleDocumentChange = (file) => {
ElMessage.success('文档文件已选择')
console.log('文档文件:', file)
}
// 处理文档文件删除
const handleDocumentRemove = () => {
ElMessage.info('文档文件已删除')
}
// 处理任意文件变化
const handleAnyFileChange = (file) => {
ElMessage.success('文件已选择')
console.log('任意文件:', file)
}
// 处理任意文件删除
const handleAnyFileRemove = () => {
ElMessage.info('文件已删除')
}
// 清空所有文件
const clearAllFiles = () => {
imageFile.value = null
documentFile.value = null
anyFile.value = null
disabledFile.value = null
ElMessage.success('所有文件已清空')
}
// 显示文件信息
const showFileInfo = () => {
const files = []
if (imageFile.value) files.push(`图片: ${imageFile.value.name}`)
if (documentFile.value) files.push(`文档: ${documentFile.value.name}`)
if (anyFile.value) files.push(`文件: ${anyFile.value.name}`)
if (files.length > 0) {
ElMessage.success(`已选择的文件: ${files.join(', ')}`)
} else {
ElMessage.info('没有选择任何文件')
}
}
</script>
<style scoped>
.file-upload-test {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.test-card {
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0 0 8px 0;
color: #1e293b;
font-size: 24px;
font-weight: 700;
}
.card-header p {
margin: 0;
color: #64748b;
font-size: 14px;
}
.test-content {
padding: 20px 0;
}
.test-section {
margin-bottom: 40px;
padding: 24px;
background: #f8fafc;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
.test-section h3 {
margin: 0 0 16px 0;
color: #1e293b;
font-size: 18px;
font-weight: 600;
}
.file-info {
margin-top: 16px;
padding: 16px;
background: white;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.file-info p {
margin: 0 0 8px 0;
font-size: 14px;
color: #374151;
}
.file-info p:last-child {
margin-bottom: 0;
}
.test-actions {
display: flex;
gap: 16px;
justify-content: center;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.file-upload-test {
padding: 10px;
}
.test-section {
padding: 16px;
margin-bottom: 24px;
}
.test-actions {
flex-direction: column;
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,341 @@
<template>
<div class="statistics-test">
<div class="test-header">
<h2>统计功能测试</h2>
<p>测试前端统计功能是否正常工作</p>
</div>
<div class="test-content">
<!-- API测试 -->
<el-card class="test-card">
<div slot="header">
<span>API接口测试</span>
</div>
<div class="test-buttons">
<el-button @click="testPublicStatistics" :loading="loading.public">测试公开统计</el-button>
<el-button @click="testUserStatistics" :loading="loading.user">测试用户统计</el-button>
<el-button @click="testMetrics" :loading="loading.metrics">测试指标列表</el-button>
<el-button @click="testDashboards" :loading="loading.dashboards">测试仪表板</el-button>
<el-button @click="testReports" :loading="loading.reports">测试报告列表</el-button>
</div>
<div class="test-results" v-if="testResults.length > 0">
<h4>测试结果</h4>
<div v-for="(result, index) in testResults" :key="index" class="result-item">
<el-tag :type="result.success ? 'success' : 'danger'">
{{ result.success ? '成功' : '失败' }}
</el-tag>
<span class="result-text">{{ result.message }}</span>
</div>
</div>
</el-card>
<!-- 组件测试 -->
<el-card class="test-card">
<div slot="header">
<span>组件测试</span>
</div>
<div class="component-tests">
<!-- StatCard测试 -->
<div class="component-test">
<h4>StatCard组件</h4>
<StatCard
title="测试指标"
value="1234"
unit="次"
trend="12.5"
icon="el-icon-data-line"
color="#409EFF"
/>
</div>
<!-- ChartCard测试 -->
<div class="component-test">
<h4>ChartCard组件</h4>
<ChartCard
title="测试图表"
subtitle="这是一个测试图表"
type="line"
:data="testChartData"
height="200"
/>
</div>
</div>
</el-card>
<!-- 页面测试 -->
<el-card class="test-card">
<div slot="header">
<span>页面测试</span>
</div>
<div class="page-tests">
<el-button @click="goToStatistics">前往统计页面</el-button>
<el-button @click="goToReports">前往报告页面</el-button>
<el-button @click="goToAnalysis">前往分析页面</el-button>
<el-button @click="goToAdminMetrics" v-if="isAdmin">前往指标管理</el-button>
</div>
</el-card>
</div>
</div>
</template>
<script>
import {
getDashboards,
getMetrics,
getPublicStatistics,
getReports,
getUserStatistics
} from '@/api/statistics'
import ChartCard from '@/components/statistics/ChartCard.vue'
import StatCard from '@/components/statistics/StatCard.vue'
export default {
name: 'StatisticsTest',
components: {
StatCard,
ChartCard
},
data() {
return {
loading: {
public: false,
user: false,
metrics: false,
dashboards: false,
reports: false
},
testResults: [],
testChartData: [
{ time: '2024-01-01', value: 100 },
{ time: '2024-01-02', value: 150 },
{ time: '2024-01-03', value: 200 },
{ time: '2024-01-04', value: 180 },
{ time: '2024-01-05', value: 250 }
]
}
},
computed: {
isAdmin() {
return this.$store.getters.userRole === 'admin'
}
},
methods: {
// 测试公开统计
async testPublicStatistics() {
this.loading.public = true
try {
const response = await getPublicStatistics()
this.addTestResult(true, '公开统计API调用成功', response)
} catch (error) {
this.addTestResult(false, '公开统计API调用失败: ' + error.message)
} finally {
this.loading.public = false
}
},
// 测试用户统计
async testUserStatistics() {
this.loading.user = true
try {
const response = await getUserStatistics()
this.addTestResult(true, '用户统计API调用成功', response)
} catch (error) {
this.addTestResult(false, '用户统计API调用失败: ' + error.message)
} finally {
this.loading.user = false
}
},
// 测试指标列表
async testMetrics() {
this.loading.metrics = true
try {
const response = await getMetrics({ limit: 10 })
this.addTestResult(true, '指标列表API调用成功', response)
} catch (error) {
this.addTestResult(false, '指标列表API调用失败: ' + error.message)
} finally {
this.loading.metrics = false
}
},
// 测试仪表板
async testDashboards() {
this.loading.dashboards = true
try {
const response = await getDashboards({ limit: 10 })
this.addTestResult(true, '仪表板API调用成功', response)
} catch (error) {
this.addTestResult(false, '仪表板API调用失败: ' + error.message)
} finally {
this.loading.dashboards = false
}
},
// 测试报告列表
async testReports() {
this.loading.reports = true
try {
const response = await getReports({ limit: 10 })
this.addTestResult(true, '报告列表API调用成功', response)
} catch (error) {
this.addTestResult(false, '报告列表API调用失败: ' + error.message)
} finally {
this.loading.reports = false
}
},
// 添加测试结果
addTestResult(success, message, data = null) {
this.testResults.unshift({
success,
message,
data,
timestamp: new Date().toLocaleTimeString()
})
// 只保留最近10条结果
if (this.testResults.length > 10) {
this.testResults = this.testResults.slice(0, 10)
}
},
// 前往统计页面
goToStatistics() {
this.$router.push('/statistics')
},
// 前往报告页面
goToReports() {
this.$router.push('/statistics/reports')
},
// 前往分析页面
goToAnalysis() {
this.$router.push('/statistics/analysis')
},
// 前往指标管理
goToAdminMetrics() {
this.$router.push('/admin/statistics/metrics')
}
}
}
</script>
<style scoped>
.statistics-test {
padding: 20px;
background-color: #f5f5f5;
min-height: 100vh;
}
.test-header {
text-align: center;
margin-bottom: 30px;
}
.test-header h2 {
margin: 0 0 8px 0;
color: #303133;
font-size: 28px;
}
.test-header p {
margin: 0;
color: #606266;
font-size: 14px;
}
.test-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.test-card {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.test-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
}
.test-results {
margin-top: 20px;
}
.test-results h4 {
margin: 0 0 12px 0;
color: #303133;
}
.result-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
padding: 8px 12px;
background: #fafafa;
border-radius: 4px;
}
.result-text {
flex: 1;
font-size: 14px;
color: #606266;
}
.component-tests {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.component-test {
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.component-test h4 {
margin: 0 0 12px 0;
color: #303133;
font-size: 16px;
}
.page-tests {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.statistics-test {
padding: 16px;
}
.test-buttons {
flex-direction: column;
}
.component-tests {
grid-template-columns: 1fr;
}
.page-tests {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,919 @@
<template>
<ListPageLayout
title="API调用记录管理"
subtitle="管理系统内所有用户的API调用记录"
>
<!-- 单用户模式显示 -->
<template #stats v-if="singleUserMode">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>当前用户{{ currentUser?.company_name || currentUser?.phone }}</span>
<span class="text-gray-400">(仅显示当前用户)</span>
</div>
</template>
<!-- 单用户模式操作按钮 -->
<template #actions v-if="singleUserMode">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-sm text-gray-600">
<User class="w-4 h-4" />
<span>{{ currentUser?.company_name || currentUser?.phone }}</span>
</div>
<el-button size="small" @click="exitSingleUserMode">
<Close class="w-4 h-4 mr-1" />
取消
</el-button>
<el-button size="small" type="primary" @click="goBackToUsers">
<Back class="w-4 h-4 mr-1" />
返回用户管理
</el-button>
</div>
</template>
<!-- 筛选区域 -->
<template #filters>
<FilterSection>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<FilterItem label="企业名称" v-if="!singleUserMode">
<el-input
v-model="filters.company_name"
placeholder="输入企业名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="产品名称">
<el-input
v-model="filters.product_name"
placeholder="输入产品名称"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="交易ID">
<el-input
v-model="filters.transaction_id"
placeholder="输入交易ID"
clearable
@input="handleFilterChange"
class="w-full"
/>
</FilterItem>
<FilterItem label="调用状态">
<el-select
v-model="filters.status"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="全部" value="" />
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
<el-option label="处理中" value="pending" />
</el-select>
</FilterItem>
<FilterItem label="调用时间" class="md:col-span-2">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
@change="handleTimeRangeChange"
class="w-full"
/>
</FilterItem>
</div>
<template #stats>
共找到 {{ total }} 条调用记录
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadApiCalls">应用筛选</el-button>
<el-button type="success" @click="showExportDialog">
<Download class="w-4 h-4 mr-1" />
导出数据
</el-button>
</template>
</FilterSection>
</template>
<!-- 表格区域 -->
<template #table>
<div v-if="loading" class="flex justify-center items-center py-12">
<el-loading size="large" />
</div>
<div v-else class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<el-table
:data="apiCalls"
style="width: 100%"
:header-cell-style="{
background: '#f8fafc',
color: '#475569',
fontWeight: '600',
fontSize: '14px'
}"
:cell-style="{
fontSize: '14px',
color: '#1e293b'
}"
>
<el-table-column prop="transaction_id" label="交易ID" min-width="180">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.transaction_id }}</span>
</template>
</el-table-column>
<el-table-column prop="company_name" label="企业名称" min-width="150" v-if="!singleUserMode">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.company_name || '未知企业' }}</div>
<div class="text-xs text-gray-500">{{ row.user?.phone }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="product_name" label="接口名称" min-width="150">
<template #default="{ row }">
<div>
<div class="font-medium text-blue-600">{{ row.product_name || '未知接口' }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag
:type="getStatusType(row.status)"
size="small"
effect="light"
>
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="error_msg" label="错误信息" min-width="200">
<template #default="{ row }">
<div v-if="row.translated_error_msg" class="error-info-cell">
<div class="translated-error">
{{ row.translated_error_msg }}
</div>
</div>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column prop="cost" label="费用" width="100">
<template #default="{ row }">
<span v-if="row.cost" class="font-semibold text-red-600">¥{{ formatPrice(row.cost) }}</span>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column prop="client_ip" label="客户端IP" width="140">
<template #default="{ row }">
<span class="font-mono text-sm text-gray-600">{{ row.client_ip }}</span>
</template>
</el-table-column>
<el-table-column prop="start_at" label="调用时间" width="160">
<template #default="{ row }">
<div class="text-sm">
<div class="text-gray-900">{{ formatDate(row.start_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.start_at) }}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="end_at" label="完成时间" width="160">
<template #default="{ row }">
<div v-if="row.end_at" class="text-sm">
<div class="text-gray-900">{{ formatDate(row.end_at) }}</div>
<div class="text-gray-500">{{ formatTime(row.end_at) }}</div>
</div>
<span v-else class="text-gray-400">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<div class="flex items-center space-x-2">
<el-button
size="small"
type="primary"
@click="handleViewDetail(row)"
>
查看详情
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</template>
<!-- 分页 -->
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<!-- API调用详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="API调用详情"
width="800px"
class="api-call-detail-dialog"
>
<div v-if="selectedApiCall" class="space-y-6">
<!-- 基本信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">基本信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">交易ID</span>
<span class="info-value font-mono">{{ selectedApiCall.transaction_id }}</span>
</div>
<div class="info-item">
<span class="info-label">状态</span>
<span class="info-value">
<el-tag :type="getStatusType(selectedApiCall.status)" size="small">
{{ getStatusText(selectedApiCall.status) }}
</el-tag>
</span>
</div>
<div class="info-item">
<span class="info-label">接口名称</span>
<span class="info-value">{{ selectedApiCall.product_name || '未知接口' }}</span>
</div>
<div class="info-item">
<span class="info-label">费用</span>
<span class="info-value">
<span v-if="selectedApiCall.cost" class="text-red-600 font-semibold">¥{{ formatPrice(selectedApiCall.cost) }}</span>
<span v-else class="text-gray-400">-</span>
</span>
</div>
<div class="info-item">
<span class="info-label">客户端IP</span>
<span class="info-value font-mono">{{ selectedApiCall.client_ip }}</span>
</div>
<div class="info-item" v-if="!singleUserMode">
<span class="info-label">企业名称</span>
<span class="info-value">{{ selectedApiCall.company_name || '未知企业' }}</span>
</div>
</div>
</div>
<!-- 时间信息 -->
<div class="info-section">
<h4 class="text-lg font-semibold text-gray-900 mb-4">时间信息</h4>
<div class="grid grid-cols-2 gap-4">
<div class="info-item">
<span class="info-label">调用时间</span>
<span class="info-value">{{ formatDateTime(selectedApiCall.start_at) }}</span>
</div>
<div class="info-item">
<span class="info-label">完成时间</span>
<span class="info-value">
<span v-if="selectedApiCall.end_at">{{ formatDateTime(selectedApiCall.end_at) }}</span>
<span v-else class="text-gray-400">-</span>
</span>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="selectedApiCall.translated_error_msg" class="error-info">
<h4 class="error-title">错误信息</h4>
<div class="error-content">
<div class="error-message">
<div class="translated-error">
{{ selectedApiCall.translated_error_msg }}
</div>
</div>
</div>
</div>
</div>
</el-dialog>
</ListPageLayout>
<!-- 导出弹窗 -->
<el-dialog
v-model="exportDialogVisible"
title="导出API调用记录"
width="600px"
:close-on-click-modal="false"
>
<div class="space-y-4">
<!-- 企业选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">选择企业</label>
<el-select
v-model="exportOptions.companyIds"
multiple
filterable
remote
reserve-keyword
placeholder="搜索并选择企业(不选则导出所有)"
class="w-full"
clearable
:remote-method="handleCompanySearch"
:loading="companyLoading"
@focus="loadCompanyOptions"
@visible-change="handleCompanyVisibleChange"
>
<el-option
v-for="company in companyOptions"
:key="company.id"
:label="company.company_name"
:value="company.id"
/>
<div v-if="companyLoading" class="text-center py-2">
<span class="text-gray-500">加载中...</span>
</div>
</el-select>
</div>
<!-- 产品选择 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">选择产品</label>
<el-select
v-model="exportOptions.productIds"
multiple
filterable
remote
reserve-keyword
placeholder="搜索并选择产品(不选则导出所有)"
class="w-full"
clearable
:remote-method="handleProductSearch"
:loading="productLoading"
@focus="loadProductOptions"
@visible-change="handleProductVisibleChange"
>
<el-option
v-for="product in productOptions"
:key="product.id"
:label="product.name"
:value="product.id"
/>
<div v-if="productLoading" class="text-center py-2">
<span class="text-gray-500">加载中...</span>
</div>
</el-select>
</div>
<!-- 时间范围 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">时间范围</label>
<el-date-picker
v-model="exportOptions.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
class="w-full"
/>
</div>
<!-- 导出格式 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">导出格式</label>
<el-radio-group v-model="exportOptions.format">
<el-radio value="excel">Excel (.xlsx)</el-radio>
<el-radio value="csv">CSV (.csv)</el-radio>
</el-radio-group>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<el-button @click="exportDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="exportLoading"
@click="handleExport"
>
确认导出
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { apiCallApi, productApi, userApi } from '@/api'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { Back, Close, Download, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 响应式数据
const loading = ref(false)
const apiCalls = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const detailDialogVisible = ref(false)
const selectedApiCall = ref(null)
const dateRange = ref([])
// 单用户模式
const singleUserMode = ref(false)
const currentUser = ref(null)
// 导出相关
const exportDialogVisible = ref(false)
const exportLoading = ref(false)
const exportOptions = reactive({
companyIds: [],
productIds: [],
dateRange: [],
format: 'excel'
})
// 企业选项
const companyOptions = ref([])
const companyLoading = ref(false)
const companySearchKeyword = ref('')
// 产品选项
const productOptions = ref([])
const productLoading = ref(false)
const productSearchKeyword = ref('')
// 筛选条件
const filters = reactive({
company_name: '',
product_name: '',
transaction_id: '',
status: '',
start_time: '',
end_time: ''
})
// 初始化
onMounted(async () => {
await checkSingleUserMode()
await loadApiCalls()
})
// 检查单用户模式
const checkSingleUserMode = async () => {
const userId = route.query.user_id
if (userId) {
singleUserMode.value = true
await loadUserInfo(userId)
}
}
// 加载用户信息
const loadUserInfo = async (userId) => {
try {
const response = await userApi.getUserDetail(userId)
currentUser.value = response.data
} catch (error) {
console.error('加载用户信息失败:', error)
ElMessage.error('加载用户信息失败')
}
}
// 加载API调用记录
const loadApiCalls = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
...filters
}
// 单用户模式添加用户ID筛选
if (singleUserMode.value && currentUser.value?.id) {
params.user_id = currentUser.value.id
}
const response = await apiCallApi.getAdminApiCalls(params)
apiCalls.value = response.data?.items || []
total.value = response.data?.total || 0
} catch (error) {
console.error('加载API调用记录失败:', error)
ElMessage.error('加载API调用记录失败')
} finally {
loading.value = false
}
}
// 格式化价格
const formatPrice = (price) => {
if (!price) return '0.00'
return Number(price).toFixed(2)
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 格式化时间
const formatTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
// 格式化日期时间
const formatDateTime = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// 获取状态类型
const getStatusType = (status) => {
switch (status) {
case 'success':
return 'success'
case 'failed':
return 'danger'
case 'pending':
return 'warning'
default:
return 'info'
}
}
// 获取状态文本
const getStatusText = (status) => {
switch (status) {
case 'success':
return '成功'
case 'failed':
return '失败'
case 'pending':
return '处理中'
default:
return '未知'
}
}
// 处理筛选变化
const handleFilterChange = () => {
currentPage.value = 1
loadApiCalls()
}
// 处理时间范围变化
const handleTimeRangeChange = (range) => {
if (range && range.length === 2) {
filters.start_time = range[0]
filters.end_time = range[1]
} else {
filters.start_time = ''
filters.end_time = ''
}
currentPage.value = 1
loadApiCalls()
}
// 重置筛选
const resetFilters = () => {
Object.keys(filters).forEach(key => {
filters[key] = ''
})
dateRange.value = []
currentPage.value = 1
loadApiCalls()
}
// 处理分页大小变化
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadApiCalls()
}
// 处理当前页变化
const handleCurrentChange = (page) => {
currentPage.value = page
loadApiCalls()
}
// 退出单用户模式
const exitSingleUserMode = () => {
singleUserMode.value = false
currentUser.value = null
router.replace({ name: 'AdminApiCalls' })
loadApiCalls()
}
// 返回用户管理
const goBackToUsers = () => {
router.push({ name: 'AdminUsers' })
}
// 查看详情
const handleViewDetail = (apiCall) => {
selectedApiCall.value = apiCall
detailDialogVisible.value = true
}
// 监听路由变化
watch(() => route.query.user_id, async (newUserId) => {
if (newUserId) {
singleUserMode.value = true
await loadUserInfo(newUserId)
} else {
singleUserMode.value = false
currentUser.value = null
}
await loadApiCalls()
})
// 导出相关方法
const showExportDialog = () => {
exportDialogVisible.value = true
}
const loadCompanyOptions = async () => {
if (companyLoading.value) return
try {
companyLoading.value = true
const response = await userApi.getUserList({
page: 1,
page_size: 1000,
is_certified: true, // 只加载已认证用户
company_name: companySearchKeyword.value
})
companyOptions.value = response.data?.items?.map(user => ({
id: user.id,
company_name: user.enterprise_info?.company_name || user.phone || '未知企业'
})) || []
} catch (error) {
console.error('加载企业选项失败:', error)
} finally {
companyLoading.value = false
}
}
const loadProductOptions = async () => {
if (productLoading.value) return
try {
productLoading.value = true
const response = await productApi.getProducts({
page: 1,
page_size: 1000,
name: productSearchKeyword.value
})
productOptions.value = response.data?.items?.map(product => ({
id: product.id,
name: product.name
})) || []
} catch (error) {
console.error('加载产品选项失败:', error)
} finally {
productLoading.value = false
}
}
const handleCompanySearch = (keyword) => {
companySearchKeyword.value = keyword
loadCompanyOptions()
}
const handleProductSearch = (keyword) => {
productSearchKeyword.value = keyword
loadProductOptions()
}
const handleCompanyVisibleChange = (visible) => {
if (visible && companyOptions.value.length === 0) {
loadCompanyOptions()
}
}
const handleProductVisibleChange = (visible) => {
if (visible && productOptions.value.length === 0) {
loadProductOptions()
}
}
const handleExport = async () => {
try {
exportLoading.value = true
// 构建导出参数
const params = {
format: exportOptions.format
}
// 添加企业筛选
if (exportOptions.companyIds.length > 0) {
params.user_ids = exportOptions.companyIds.join(',')
}
// 添加产品筛选
if (exportOptions.productIds.length > 0) {
params.product_ids = exportOptions.productIds.join(',')
}
// 添加时间范围筛选
if (exportOptions.dateRange && exportOptions.dateRange.length === 2) {
params.start_time = exportOptions.dateRange[0]
params.end_time = exportOptions.dateRange[1]
}
// 调用导出API
const response = await apiCallApi.exportAdminApiCalls(params)
// 创建下载链接
const blob = new Blob([response], {
type: exportOptions.format === 'excel'
? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
: 'text/csv;charset=utf-8'
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `API调用记录.${exportOptions.format === 'excel' ? 'xlsx' : 'csv'}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
exportDialogVisible.value = false
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败,请稍后重试')
} finally {
exportLoading.value = false
}
}
</script>
<style scoped>
.info-section {
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
padding: 1rem;
}
.info-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.info-label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
min-width: 100px;
}
.info-value {
font-size: 0.875rem;
color: #111827;
flex: 1;
}
/* 错误信息样式 */
.error-info {
background: rgba(254, 242, 242, 0.8);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
padding: 16px;
}
.error-title {
font-size: 16px;
font-weight: 600;
color: #dc2626;
margin-bottom: 12px;
}
.error-content {
space-y: 2;
}
.error-message {
font-size: 14px;
color: #7f1d1d;
line-height: 1.5;
}
.translated-error {
font-weight: 500;
color: #dc2626;
margin-bottom: 4px;
}
.error-info-cell {
max-width: 200px;
}
.error-info-cell .translated-error {
font-weight: 500;
color: #dc2626;
margin-bottom: 4px;
font-size: 13px;
line-height: 1.4;
}
.api-call-detail-dialog :deep(.el-dialog) {
border-radius: 16px;
overflow: hidden;
}
.api-call-detail-dialog :deep(.el-dialog__header) {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
padding: 20px 24px;
}
.api-call-detail-dialog :deep(.el-dialog__title) {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.api-call-detail-dialog :deep(.el-dialog__body) {
padding: 24px;
max-height: 70vh;
overflow-y: auto;
}
/* 表格样式优化 */
:deep(.el-table) {
border-radius: 8px;
overflow: hidden;
}
:deep(.el-table th) {
background: #f8fafc !important;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-table td) {
border-bottom: 1px solid #f1f5f9;
}
:deep(.el-table tr:hover > td) {
background: #f8fafc !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.info-item {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.info-label {
min-width: auto;
}
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<div>
<!-- 页面头部 -->
<div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">文章分类管理</h1>
<p class="mt-1 text-sm text-gray-500">管理文章分类信息</p>
</div>
<div class="flex space-x-3">
<el-button type="primary" @click="handleCreateCategory">
<el-icon class="mr-1"><PlusIcon /></el-icon>
新增分类
</el-button>
</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 分类列表 -->
<div>
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">分类列表</h3>
</div>
<el-table
v-loading="loading"
:data="categories"
stripe
class="w-full"
>
<el-table-column prop="name" label="分类名称" min-width="150">
<template #default="{ row }">
<span class="font-medium text-gray-900">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="分类描述" min-width="200">
<template #default="{ row }">
<span class="text-gray-600">{{ row.description || '暂无描述' }}</span>
</template>
</el-table-column>
<el-table-column prop="article_count" label="文章数量" width="100" align="center">
<template #default="{ row }">
<span class="text-gray-600">{{ row.article_count || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
<span class="text-gray-600">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<div class="flex space-x-2">
<el-button
type="primary"
size="small"
@click="handleEditCategory(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDeleteCategory(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 分类编辑对话框 -->
<CategoryEditDialog
v-model="showEditDialog"
:category="currentCategory"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup>
import { articleApi } from '@/api'
import { PlusIcon } from '@heroicons/vue/24/outline'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import CategoryEditDialog from './components/CategoryEditDialog.vue'
// 响应式数据
const loading = ref(false)
const categories = ref([])
// 对话框控制
const showEditDialog = ref(false)
const currentCategory = ref(null)
// 获取分类列表
const loadCategories = async () => {
loading.value = true
try {
const response = await articleApi.getCategories()
// 后端返回 { items, total },表格需要数组
categories.value = Array.isArray(response.data)
? response.data
: (response.data?.items || [])
} catch (error) {
ElMessage.error('获取分类列表失败')
console.error('获取分类列表失败:', error)
} finally {
loading.value = false
}
}
// 新增分类
const handleCreateCategory = () => {
currentCategory.value = null
showEditDialog.value = true
}
// 编辑分类
const handleEditCategory = (category) => {
currentCategory.value = category
showEditDialog.value = true
}
// 删除分类
const handleDeleteCategory = async (category) => {
try {
await ElMessageBox.confirm(
`确定要删除分类"${category.name}"吗?删除后无法恢复!`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await articleApi.deleteCategory(category.id)
ElMessage.success('分类删除成功')
loadCategories()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除分类失败')
console.error('删除分类失败:', error)
}
}
}
// 编辑成功回调
const handleEditSuccess = () => {
loadCategories()
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 页面初始化
onMounted(() => {
loadCategories()
})
</script>

View File

@@ -0,0 +1,224 @@
<template>
<el-dialog
v-model="dialogVisible"
title="文章详情"
width="70%"
:close-on-click-modal="false"
@open="handleDialogOpen"
v-loading="loading"
>
<div v-if="article" class="space-y-6">
<!-- 基本信息 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">基本信息</h3>
<el-descriptions :column="2" border>
<el-descriptions-item label="文章标题">
<span class="font-medium">{{ article.title }}</span>
</el-descriptions-item>
<el-descriptions-item label="文章状态">
<el-tag :type="getStatusType(article.status)">
{{ getStatusText(article.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文章分类">
{{ article.category?.name || '未分类' }}
</el-descriptions-item>
<el-descriptions-item label="推荐状态">
<el-tag :type="article.is_featured ? 'success' : 'info'">
{{ article.is_featured ? '推荐' : '普通' }}
</el-tag>
</el-descriptions-item>
<!-- <el-descriptions-item label="阅读量">
{{ article.view_count || 0 }}
</el-descriptions-item> -->
<el-descriptions-item label="创建时间">
{{ formatDate(article.created_at) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ formatDate(article.updated_at) }}
</el-descriptions-item>
<el-descriptions-item label="发布时间">
{{ article.published_at ? formatDate(article.published_at) : '-' }}
</el-descriptions-item>
<el-descriptions-item v-if="article.scheduled_at" label="定时发布时间">
{{ formatDate(article.scheduled_at) }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 文章摘要 -->
<div v-if="article.summary" class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章摘要</h3>
<p class="text-gray-700 leading-relaxed">{{ article.summary }}</p>
</div>
<!-- 封面图片 -->
<div v-if="article.cover_image" class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">封面图片</h3>
<div class="flex justify-center">
<img
:src="article.cover_image"
:alt="article.title"
class="max-w-full h-auto max-h-64 rounded-lg shadow-sm"
@error="handleImageError"
/>
</div>
</div>
<!-- 文章标签 -->
<div v-if="article.tags && article.tags.length > 0" class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章标签</h3>
<div class="flex flex-wrap gap-2">
<el-tag
v-for="tag in article.tags"
:key="tag.id"
:style="{ backgroundColor: tag.color + '20', color: tag.color, borderColor: tag.color }"
>
{{ tag.name }}
</el-tag>
</div>
</div>
<!-- 文章内容 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章内容</h3>
<div class="bg-white p-4 rounded border">
<div class="prose max-w-none">
<div v-html="article.content" class="text-gray-700 leading-relaxed"></div>
</div>
</div>
</div>
</div>
<div v-else class="flex justify-center items-center py-8">
<el-empty description="暂无文章数据" />
</div>
<template #footer>
<div class="flex justify-end">
<el-button @click="dialogVisible = false">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api'
import { ElMessage } from 'element-plus'
import { computed, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue'])
// 响应式数据
const loading = ref(false)
const articleDetail = ref(null)
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 获取文章详情
const fetchArticleDetail = async (articleId) => {
if (!articleId) return
loading.value = true
try {
const response = await articleApi.getArticleDetail(articleId)
articleDetail.value = response.data
} catch (error) {
ElMessage.error('获取文章详情失败')
console.error('获取文章详情失败:', error)
} finally {
loading.value = false
}
}
// 对话框打开时获取详情
const handleDialogOpen = () => {
if (props.article?.id) {
fetchArticleDetail(props.article.id)
}
}
// 监听文章变化
watch(() => props.article, (newArticle) => {
if (newArticle?.id && dialogVisible.value) {
fetchArticleDetail(newArticle.id)
}
}, { immediate: true })
// 使用详情数据或props数据
const article = computed(() => {
return articleDetail.value || props.article
})
// 状态类型映射
const getStatusType = (status) => {
const statusMap = {
draft: 'info',
published: 'success',
archived: 'warning'
}
return statusMap[status] || 'info'
}
// 状态文本映射
const getStatusText = (status) => {
const statusMap = {
draft: '草稿',
published: '已发布',
archived: '已归档'
}
return statusMap[status] || status
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 图片加载错误处理
const handleImageError = (event) => {
event.target.style.display = 'none'
}
</script>
<style scoped>
.prose {
font-size: 14px;
line-height: 1.6;
}
.prose pre {
font-family: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
}
</style>

View File

@@ -0,0 +1,339 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑文章' : '新增文章'"
width="80%"
:close-on-click-modal="false"
@open="handleDialogOpen"
v-loading="loading"
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="space-y-6">
<!-- 基本信息 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">基本信息</h3>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="文章标题" prop="title">
<el-input v-model="form.title" placeholder="请输入文章标题" maxlength="200" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="文章分类" prop="category_id">
<el-select v-model="form.category_id" placeholder="选择分类" clearable class="w-full">
<el-option v-for="category in categories" :key="category.id" :label="category.name"
:value="category.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="文章摘要" prop="summary">
<el-input v-model="form.summary" type="textarea" :rows="3" placeholder="请输入文章摘要" maxlength="500"
show-word-limit />
</el-form-item>
<el-form-item label="封面图片" prop="cover_image">
<el-input v-model="form.cover_image" placeholder="请输入封面图片URL" />
</el-form-item>
<el-form-item label="文章标签" prop="tag_ids">
<el-select v-model="form.tag_ids" multiple placeholder="选择标签" clearable class="w-full">
<el-option v-for="tag in tags" :key="tag.id" :label="tag.name" :value="tag.id">
<div class="flex items-center">
<div class="w-4 h-4 rounded mr-2" :style="{ backgroundColor: tag.color }"></div>
{{ tag.name }}
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="推荐状态" prop="is_featured">
<el-switch v-model="form.is_featured" active-text="推荐" inactive-text="普通" />
</el-form-item>
</div>
<!-- 文章内容 -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-gray-900 mb-4">文章内容</h3>
<el-form-item label="文章内容" prop="content">
<Editor style="width: 100%;" v-model="form.content" :init="editorInit"
tinymceScriptSrc="https://cdn.jsdelivr.net/npm/tinymce@7.9.1/tinymce.min.js" />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api';
import Editor from '@tinymce/tinymce-vue';
import { ElMessage } from 'element-plus';
import { computed, reactive, ref, watch } from 'vue';
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
},
categories: {
type: Array,
default: () => []
},
tags: {
type: Array,
default: () => []
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const formRef = ref(null)
const loading = ref(false)
const articleDetail = ref(null)
// 表单数据
const form = reactive({
title: '',
content: '',
summary: '',
cover_image: '',
category_id: '',
tag_ids: [],
is_featured: false
})
// 表单验证规则
const rules = {
title: [
{ required: true, message: '请输入文章标题', trigger: 'blur' },
{ min: 1, max: 200, message: '标题长度在 1 到 200 个字符', trigger: 'blur' }
],
content: [
{ required: true, message: '请输入文章内容', trigger: 'blur' }
],
summary: [
{ max: 500, message: '摘要长度不能超过 500 个字符', trigger: 'blur' }
]
}
// TinyMCE 配置
const editorInit = {
menubar: false,
dragdrop: true, // 启用拖拽图片功能
valid_elements: '*[*]',
valid_elements: 'section[*],*[*]', // 允许 section 及其属性
valid_children: '+body[section],+section[p,div,span]', // 允许 body 包含 sectionsection 包含段落等
file_picker_types: 'image',
invalid_elements: 'script',
statusbar: false,
placeholder: '开始编写吧', // 占位符
theme: 'silver', // 主题 必须引入
license_key: 'gpl', // 使用开源许可
paste_as_text: false, // 允许 HTML 粘贴
paste_enable_default_filters: false, // 禁用默认 HTML 过滤
paste_webkit_styles: 'all', // 允许所有 Webkit 内联样式
paste_retain_style_properties: 'all', // 保留所有 inline style
extended_valid_elements: '*[*]', // 确保所有 HTML 属性都被保留
promotion: false, // 移除 Upgrade 按钮
branding: false, // 移除 TinyMCE 品牌信息
toolbar_mode: 'wrap',
contextmenu: 'styleControls | insertBefore insertAfter | copyElement | removeIndent | deleteElement | image link',
content_style: `
body::-webkit-scrollbar {
width: 8px;
}
body::-webkit-scrollbar-track {
background: #f8fafc;
}
body::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
body::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.element-highlight {
outline: 1px solid #3b82f6 !important;
}
`,
// 设置工具栏样式
toolbar_location: 'top',
// 如果需要固定工具栏
toolbar_sticky: true,
toolbar: [
'undo redo bold italic underline forecolor backcolor alignleft aligncenter alignright image insertBeforeSection insertSection styleControls mySaveBtn help_article'
]
,
plugins: [
'anchor', 'autolink', 'charmap', 'codesample', 'emoticons', 'image', 'link',
'lists', 'media', 'searchreplace', 'table', 'visualblocks', 'wordcount'
],
setup: function (editor) {
editor.on('paste', function (e) {
e.preventDefault(); // 阻止默认粘贴行为
const clipboardData = e.clipboardData || window.clipboardData;
if (clipboardData && clipboardData.types.indexOf('text/html') > -1) {
// 获取原始HTML内容
const htmlContent = clipboardData.getData('text/html');
// 直接插入原始HTML避免修改
editor.insertContent(htmlContent);
} else {
// 如果没有HTML回退到纯文本
const text = clipboardData.getData('text/plain');
editor.insertContent(text);
}
});
}
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.article)
// 获取文章详情
const fetchArticleDetail = async (articleId) => {
if (!articleId) return
loading.value = true
try {
const response = await articleApi.getArticleDetail(articleId)
articleDetail.value = response.data
// 使用详情数据填充表单
fillFormWithDetail(articleDetail.value)
} catch (error) {
ElMessage.error('获取文章详情失败')
console.error('获取文章详情失败:', error)
} finally {
loading.value = false
}
}
// 使用详情数据填充表单
const fillFormWithDetail = (detail) => {
if (!detail) return
Object.assign(form, {
title: detail.title || '',
content: detail.content || '',
summary: detail.summary || '',
cover_image: detail.cover_image || '',
category_id: detail.category_id || '',
tag_ids: detail.tags ? detail.tags.map(tag => tag.id) : [],
is_featured: detail.is_featured || false
})
}
// 对话框打开时获取详情
const handleDialogOpen = () => {
if (props.article?.id && isEdit.value) {
fetchArticleDetail(props.article.id)
}
}
// 监听文章数据变化,初始化表单
watch(() => props.article, (newArticle) => {
if (newArticle && isEdit.value) {
// 编辑模式如果有详情数据则使用详情数据否则使用props数据
if (articleDetail.value) {
fillFormWithDetail(articleDetail.value)
} else {
// 使用props数据作为临时填充
Object.assign(form, {
title: newArticle.title || '',
content: newArticle.content || '',
summary: newArticle.summary || '',
cover_image: newArticle.cover_image || '',
category_id: newArticle.category_id || '',
tag_ids: newArticle.tag_ids || [],
is_featured: newArticle.is_featured || false
})
}
} else {
// 新增模式,重置表单
resetForm()
}
}, { immediate: true })
// 重置表单(使用函数声明,避免提升前调用报错)
function resetForm() {
Object.assign(form, {
title: '',
content: '',
summary: '',
cover_image: '',
category_id: '',
tag_ids: [],
is_featured: false
})
if (formRef.value) {
formRef.value.clearValidate()
}
}
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
if (isEdit.value) {
// 编辑模式
await articleApi.updateArticle(props.article.id, form)
ElMessage.success('文章更新成功')
} else {
// 新增模式
await articleApi.createArticle(form)
ElMessage.success('文章创建成功')
}
emit('success')
handleClose()
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
} else {
ElMessage.error(isEdit.value ? '更新文章失败' : '创建文章失败')
}
console.error('提交表单失败:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-blue-50 rounded-md flex items-center justify-center">
<el-icon class="text-blue-600 text-sm"><DocumentTextIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">总文章数</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.total_articles || 0 }}</p>
</div>
</div>
</div>
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-green-50 rounded-md flex items-center justify-center">
<el-icon class="text-green-600 text-sm"><CheckCircleIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">已发布</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.published_articles || 0 }}</p>
</div>
</div>
</div>
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-yellow-50 rounded-md flex items-center justify-center">
<el-icon class="text-yellow-600 text-sm"><ClockIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">草稿</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.draft_articles || 0 }}</p>
</div>
</div>
</div>
<div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-orange-50 rounded-md flex items-center justify-center">
<el-icon class="text-orange-600 text-sm"><ArchiveBoxIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">已归档</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.archived_articles || 0 }}</p>
</div>
</div>
</div>
<!-- <div class="rounded-md bg-gray-50 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-6 h-6 bg-purple-50 rounded-md flex items-center justify-center">
<el-icon class="text-purple-600 text-sm"><EyeIcon /></el-icon>
</div>
</div>
<div class="ml-3">
<p class="text-xs text-gray-500">总阅读量</p>
<p class="text-xl font-semibold text-gray-900">{{ stats.total_views || 0 }}</p>
</div>
</div>
</div> -->
</div>
</template>
<script setup>
import { ArchiveBoxIcon, CheckCircleIcon, ClockIcon, DocumentTextIcon } from '@heroicons/vue/24/outline';
defineProps({
stats: {
type: Object,
default: () => ({})
}
})
</script>

View File

@@ -0,0 +1,160 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑分类' : '新增分类'"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入分类名称"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入分类描述"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
category: {
type: Object,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const formRef = ref(null)
const loading = ref(false)
// 表单数据
const form = reactive({
name: '',
description: ''
})
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 1, max: 100, message: '名称长度在 1 到 100 个字符', trigger: 'blur' }
],
description: [
{ max: 500, message: '描述长度不能超过 500 个字符', trigger: 'blur' }
]
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.category)
// 重置表单
const resetForm = () => {
Object.assign(form, {
name: '',
description: ''
})
if (formRef.value) {
formRef.value.clearValidate()
}
}
// 监听分类数据变化,初始化表单
watch(() => props.category, (newCategory) => {
if (newCategory) {
// 编辑模式,填充表单数据
Object.assign(form, {
name: newCategory.name || '',
description: newCategory.description || ''
})
} else {
// 新增模式,重置表单
resetForm()
}
}, { immediate: true })
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
if (isEdit.value) {
// 编辑模式
await articleApi.updateCategory(props.category.id, form)
ElMessage.success('分类更新成功')
} else {
// 新增模式
await articleApi.createCategory(form)
ElMessage.success('分类创建成功')
}
emit('success')
handleClose()
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
} else {
ElMessage.error(isEdit.value ? '更新分类失败' : '创建分类失败')
}
console.error('提交表单失败:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,206 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="article?.scheduled_at ? '修改定时发布时间' : '定时发布文章'"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<el-form-item label="文章标题">
<div class="text-gray-600">{{ article?.title }}</div>
</el-form-item>
<el-form-item label="文章标签" v-if="article?.tags && article.tags.length > 0">
<div class="flex flex-wrap gap-1">
<el-tag
v-for="tag in article.tags"
:key="tag.id"
:color="tag.color"
size="small"
class="text-xs"
>
{{ tag.name }}
</el-tag>
</div>
</el-form-item>
<el-form-item label="定时发布日期" prop="scheduled_date">
<el-date-picker
v-model="form.scheduled_date"
type="date"
placeholder="选择定时发布日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:disabled-date="disabledDate"
class="w-full"
/>
</el-form-item>
<el-form-item label="定时发布时间" prop="scheduled_time">
<el-time-picker
v-model="form.scheduled_time"
placeholder="选择定时发布时间"
format="HH:mm:ss"
value-format="HH:mm:ss"
:disabled="!form.scheduled_date"
class="w-full"
/>
</el-form-item>
<el-form-item label="提示信息">
<div class="text-sm text-gray-500">
<p> 定时发布日期不能早于今天</p>
<p> 设置后文章将保持草稿状态到指定时间自动发布</p>
<p> 可以随时取消定时发布重新设置</p>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ article?.scheduled_at ? '确认修改' : '确认设置' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api/article'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
article: {
type: Object,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const loading = ref(false)
const formRef = ref()
// 表单数据
const form = reactive({
scheduled_date: '',
scheduled_time: ''
})
// 表单验证规则
const rules = {
scheduled_date: [
{ required: true, message: '请选择定时发布日期', trigger: 'change' }
],
scheduled_time: [
{ required: true, message: '请选择定时发布时间', trigger: 'change' }
]
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 禁用过去的日期
const disabledDate = (time) => {
const today = new Date()
today.setHours(0, 0, 0, 0) // 设置为今天的开始时间
return time.getTime() < today.getTime() // 禁用今天之前的日期
}
// 监听对话框显示状态
watch(() => props.modelValue, (visible) => {
if (visible && props.article) {
if (props.article.scheduled_at) {
// 如果已有定时时间,使用现有时间
const scheduledDate = new Date(props.article.scheduled_at)
// 使用本地时间格式化,避免时区问题
const year = scheduledDate.getFullYear()
const month = String(scheduledDate.getMonth() + 1).padStart(2, '0')
const day = String(scheduledDate.getDate()).padStart(2, '0')
form.scheduled_date = `${year}-${month}-${day}`
const hours = String(scheduledDate.getHours()).padStart(2, '0')
const minutes = String(scheduledDate.getMinutes()).padStart(2, '0')
const seconds = String(scheduledDate.getSeconds()).padStart(2, '0')
form.scheduled_time = `${hours}:${minutes}:${seconds}`
} else {
// 设置默认日期为今天,使用本地时间
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
form.scheduled_date = `${year}-${month}-${day}`
// 设置默认时间为当前时间后1小时
const defaultTime = new Date()
defaultTime.setHours(defaultTime.getHours() + 1)
const hours = String(defaultTime.getHours()).padStart(2, '0')
const minutes = String(defaultTime.getMinutes()).padStart(2, '0')
const seconds = String(defaultTime.getSeconds()).padStart(2, '0')
form.scheduled_time = `${hours}:${minutes}:${seconds}`
}
}
})
// 处理关闭
const handleClose = () => {
dialogVisible.value = false
form.scheduled_date = ''
form.scheduled_time = ''
}
// 处理提交
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch (error) {
return
}
loading.value = true
try {
// 根据是否已有定时时间来选择不同的API
if (props.article.scheduled_at) {
// 修改定时发布时间
await articleApi.updateSchedulePublishArticle(props.article.id, {
scheduled_time: `${form.scheduled_date} ${form.scheduled_time}`
})
} else {
// 设置定时发布
await articleApi.schedulePublishArticle(props.article.id, {
scheduled_time: `${form.scheduled_date} ${form.scheduled_time}`
})
}
ElMessage.success(props.article.scheduled_at ? '定时发布时间修改成功' : '定时发布设置成功')
emit('success')
handleClose()
} catch (error) {
ElMessage.error(error.message || '设置定时发布失败')
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,178 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑标签' : '新增标签'"
width="500px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="标签名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入标签名称"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="标签颜色" prop="color">
<div class="flex items-center space-x-3">
<el-color-picker
v-model="form.color"
show-alpha
:predefine="predefineColors"
/>
<el-input
v-model="form.color"
placeholder="请输入颜色值"
class="flex-1"
/>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end space-x-3">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { articleApi } from '@/api'
import { ElMessage } from 'element-plus'
import { computed, reactive, ref, watch } from 'vue'
// Props
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
tag: {
type: Object,
default: null
}
})
// Emits
const emit = defineEmits(['update:modelValue', 'success'])
// 响应式数据
const formRef = ref(null)
const loading = ref(false)
// 表单数据
const form = reactive({
name: '',
color: '#1890ff'
})
// 预定义颜色
const predefineColors = [
'#1890ff',
'#52c41a',
'#faad14',
'#f5222d',
'#722ed1',
'#13c2c2',
'#eb2f96',
'#fa8c16',
'#a0d911',
'#2f54eb'
]
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入标签名称', trigger: 'blur' },
{ min: 1, max: 50, message: '名称长度在 1 到 50 个字符', trigger: 'blur' }
],
color: [
{ required: true, message: '请选择标签颜色', trigger: 'blur' }
]
}
// 计算属性
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const isEdit = computed(() => !!props.tag)
// 重置表单
const resetForm = () => {
Object.assign(form, {
name: '',
color: '#1890ff'
})
if (formRef.value) {
formRef.value.clearValidate()
}
}
// 监听标签数据变化,初始化表单
watch(() => props.tag, (newTag) => {
if (newTag) {
// 编辑模式,填充表单数据
Object.assign(form, {
name: newTag.name || '',
color: newTag.color || '#1890ff'
})
} else {
// 新增模式,重置表单
resetForm()
}
}, { immediate: true })
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
if (isEdit.value) {
// 编辑模式
await articleApi.updateTag(props.tag.id, form)
ElMessage.success('标签更新成功')
} else {
// 新增模式
await articleApi.createTag(form)
ElMessage.success('标签创建成功')
}
emit('success')
handleClose()
} catch (error) {
if (error.message) {
ElMessage.error(error.message)
} else {
ElMessage.error(isEdit.value ? '更新标签失败' : '创建标签失败')
}
console.error('提交表单失败:', error)
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,654 @@
<template>
<ListPageLayout
title="文章管理"
subtitle="管理系统中的所有文章内容"
>
<template #actions>
<el-button @click="showCategoryDialog = true">
<el-icon class="mr-1"><TagIcon /></el-icon>
分类管理
</el-button>
<el-button @click="showTagDialog = true">
<el-icon class="mr-1"><TagIcon /></el-icon>
标签管理
</el-button>
<el-button type="primary" @click="handleCreateArticle">
<el-icon class="mr-1"><PlusIcon /></el-icon>
新增文章
</el-button>
</template>
<template #filters>
<FilterSection>
<FilterItem label="文章状态">
<el-select
v-model="filters.status"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="草稿" value="draft" />
<el-option label="已发布" value="published" />
<el-option label="已归档" value="archived" />
</el-select>
</FilterItem>
<FilterItem label="文章分类">
<el-select
v-model="filters.category_id"
placeholder="选择分类"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option
v-for="category in categories"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</FilterItem>
<FilterItem label="推荐状态">
<el-select
v-model="filters.is_featured"
placeholder="选择状态"
clearable
@change="handleFilterChange"
class="w-full"
>
<el-option label="推荐" :value="true" />
<el-option label="普通" :value="false" />
</el-select>
</FilterItem>
<FilterItem label="标题关键词">
<el-input
v-model="filters.title"
placeholder="输入文章标题关键词"
clearable
@input="handleSearch"
class="w-full"
>
<template #prefix>
<el-icon><MagnifyingGlassIcon /></el-icon>
</template>
</el-input>
</FilterItem>
<template #stats>
共找到 {{ total }} 篇文章
</template>
<template #buttons>
<el-button @click="resetFilters">重置筛选</el-button>
<el-button type="primary" @click="loadArticles">应用筛选</el-button>
</template>
</FilterSection>
</template>
<template #table>
<!-- 统计卡片 -->
<div class="mb-6">
<ArticleStats :stats="stats" />
</div>
<!-- 文章列表表格 -->
<el-table
v-loading="loading"
:data="articles"
stripe
class="w-full"
>
<el-table-column prop="title" label="文章标题" min-width="200">
<template #default="{ row }">
<div class="flex items-center">
<span class="font-medium text-blue-600 cursor-pointer hover:text-blue-800" @click="handleViewArticle(row)">
{{ row.title }}
</span>
<el-tag v-if="row.is_featured" type="success" size="small" class="ml-2">推荐</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="category.name" label="分类" width="120">
<template #default="{ row }">
{{ row.category?.name || '未分类' }}
</template>
</el-table-column>
<el-table-column prop="tags" label="标签" width="200">
<template #default="{ row }">
<div class="flex flex-wrap gap-1">
<el-tag
v-for="tag in row.tags"
:key="tag.id"
:color="tag.color"
size="small"
class="text-xs"
>
{{ tag.name }}
</el-tag>
<span v-if="!row.tags || row.tags.length === 0" class="text-gray-400 text-xs">
无标签
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<div class="flex flex-col gap-1">
<el-tag
:type="getStatusType(row.status, row.scheduled_at)"
size="small"
>
{{ getStatusText(row.status, row.scheduled_at) }}
</el-tag>
<!-- 定时发布信息 -->
<div v-if="row.scheduled_at" class="text-xs text-gray-500">
<div>定时: {{ formatDate(row.scheduled_at) }}</div>
</div>
</div>
</template>
</el-table-column>
<!-- <el-table-column prop="view_count" label="阅读量" width="100" align="center">
<template #default="{ row }">
<span class="text-gray-600">{{ row.view_count || 0 }}</span>
</template>
</el-table-column> -->
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
<span class="text-gray-600">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column prop="published_at" label="发布时间" width="180">
<template #default="{ row }">
<span class="text-gray-600">{{ row.published_at ? formatDate(row.published_at) : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" min-width="200" fixed="right">
<template #default="{ row }">
<div class="flex gap-2">
<!-- 主要操作按钮 -->
<el-button
v-if="row.status === 'draft'"
type="success"
size="small"
@click="handlePublishArticle(row)"
>
发布
</el-button>
<el-button
v-if="row.status === 'published'"
type="warning"
size="small"
@click="handleArchiveArticle(row)"
>
归档
</el-button>
<el-button
type="primary"
size="small"
@click="handleEditArticle(row)"
>
编辑
</el-button>
<!-- 更多操作下拉菜单 -->
<el-dropdown @command="(command) => handleMoreAction(command, row)">
<el-button size="small" type="info">
更多<el-icon class="el-icon--right"><ChevronDownIcon /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<!-- 定时发布相关操作 -->
<el-dropdown-item
v-if="row.status === 'draft' && !row.scheduled_at"
command="schedule-publish"
>
<div class="flex items-center gap-2">
<ClockIcon class="w-4 h-4" />
<span>定时发布</span>
</div>
</el-dropdown-item>
<el-dropdown-item
v-if="row.status === 'draft' && row.scheduled_at"
command="schedule-publish"
>
<div class="flex items-center gap-2">
<ClockIcon class="w-4 h-4" />
<span>修改时间</span>
</div>
</el-dropdown-item>
<el-dropdown-item
v-if="row.status === 'draft' && row.scheduled_at"
command="cancel-schedule"
divided
>
<div class="flex items-center gap-2">
<XMarkIcon class="w-4 h-4" />
<span>取消定时</span>
</div>
</el-dropdown-item>
<!-- 查看操作 -->
<el-dropdown-item command="view">
<div class="flex items-center gap-2">
<EyeIcon class="w-4 h-4" />
<span>查看</span>
</div>
</el-dropdown-item>
<!-- 删除操作 -->
<el-dropdown-item command="delete" divided>
<div class="flex items-center gap-2">
<TrashIcon class="w-4 h-4" />
<span>删除</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</template>
<template #pagination>
<el-pagination
v-if="total > 0"
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</template>
<template #extra>
<!-- 文章编辑对话框 -->
<ArticleEditDialog
v-model="showEditDialog"
:article="currentArticle"
:categories="categories"
:tags="tags"
@success="handleEditSuccess"
/>
<!-- 文章详情对话框 -->
<ArticleDetailDialog
v-model="showDetailDialog"
:article="currentArticle"
/>
<!-- 定时发布对话框 -->
<SchedulePublishDialog
v-model="showScheduleDialog"
:article="currentArticle"
@success="handleScheduleSuccess"
/>
<!-- 分类管理对话框 -->
<el-dialog
v-model="showCategoryDialog"
title="分类管理"
width="80%"
:close-on-click-modal="false"
@close="handleCategoryDialogClose"
>
<Categories />
</el-dialog>
<!-- 标签管理对话框 -->
<el-dialog
v-model="showTagDialog"
title="标签管理"
width="80%"
:close-on-click-modal="false"
@close="handleTagDialogClose"
>
<Tags />
</el-dialog>
</template>
</ListPageLayout>
</template>
<script setup>
import { articleApi } from '@/api'
import FilterItem from '@/components/common/FilterItem.vue'
import FilterSection from '@/components/common/FilterSection.vue'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { ChevronDownIcon, ClockIcon, EyeIcon, MagnifyingGlassIcon, PlusIcon, TagIcon, TrashIcon, XMarkIcon } from '@heroicons/vue/24/outline'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue'
import Categories from './categories.vue'
import ArticleDetailDialog from './components/ArticleDetailDialog.vue'
import ArticleEditDialog from './components/ArticleEditDialog.vue'
import ArticleStats from './components/ArticleStats.vue'
import SchedulePublishDialog from './components/SchedulePublishDialog.vue'
import Tags from './tags.vue'
// 响应式数据
const loading = ref(false)
const articles = ref([])
const categories = ref([])
const tags = ref([])
const total = ref(0)
const stats = ref({})
// 筛选器
const filters = reactive({
status: '',
category_id: '',
is_featured: '',
title: ''
})
// 分页
const pagination = reactive({
page: 1,
pageSize: 10
})
// 搜索防抖
let searchTimer = null
// 对话框控制
const showEditDialog = ref(false)
const showDetailDialog = ref(false)
const showCategoryDialog = ref(false)
const showTagDialog = ref(false)
const showScheduleDialog = ref(false)
const currentArticle = ref(null)
// 获取文章列表
const loadArticles = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...filters
}
// 处理推荐状态参数
if (params.is_featured === '') {
delete params.is_featured
}
const response = await articleApi.getArticlesForAdmin(params)
articles.value = response.data.items || []
total.value = response.data.total || 0
} catch (error) {
console.error('获取文章列表失败:', error)
} finally {
loading.value = false
}
}
// 获取统计数据
const loadStats = async () => {
try {
const response = await articleApi.getArticleStats()
stats.value = response.data || {}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
// 获取分类列表
const loadCategories = async () => {
try {
const response = await articleApi.getCategories()
categories.value = Array.isArray(response.data)
? response.data
: (response.data?.items || [])
} catch (error) {
console.error('获取分类列表失败:', error)
}
}
// 获取标签列表
const loadTags = async () => {
try {
const response = await articleApi.getTags()
tags.value = Array.isArray(response.data)
? response.data
: (response.data?.items || [])
} catch (error) {
console.error('获取标签列表失败:', error)
}
}
// 筛选器变化处理
const handleFilterChange = () => {
pagination.page = 1
loadArticles()
}
// 搜索处理
const handleSearch = () => {
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
pagination.page = 1
loadArticles()
}, 500)
}
// 重置筛选器
const resetFilters = () => {
Object.keys(filters).forEach(key => {
filters[key] = ''
})
pagination.page = 1
loadArticles()
}
// 分页处理
const handleSizeChange = (size) => {
pagination.pageSize = size
pagination.page = 1
loadArticles()
}
const handleCurrentChange = (page) => {
pagination.page = page
loadArticles()
}
// 新增文章
const handleCreateArticle = () => {
currentArticle.value = null
showEditDialog.value = true
}
// 编辑文章
const handleEditArticle = (article) => {
currentArticle.value = { id: article.id } // 只传递ID让编辑弹窗自己获取完整数据
showEditDialog.value = true
}
// 查看文章详情
const handleViewArticle = (article) => {
currentArticle.value = { id: article.id } // 只传递ID让详情弹窗自己获取完整数据
showDetailDialog.value = true
}
// 发布文章
const handlePublishArticle = async (article) => {
try {
await ElMessageBox.confirm('确定要发布这篇文章吗?', '确认发布', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await articleApi.publishArticle(article.id)
ElMessage.success('文章发布成功')
loadArticles()
} catch (error) {
if (error !== 'cancel') {
console.error('发布文章失败:', error)
}
}
}
// 归档文章
const handleArchiveArticle = async (article) => {
try {
await ElMessageBox.confirm('确定要归档这篇文章吗?', '确认归档', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await articleApi.archiveArticle(article.id)
ElMessage.success('文章归档成功')
loadArticles()
} catch (error) {
if (error !== 'cancel') {
console.error('归档文章失败:', error)
}
}
}
// 删除文章
const handleDeleteArticle = async (article) => {
try {
await ElMessageBox.confirm('确定要删除这篇文章吗?删除后无法恢复!', '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await articleApi.deleteArticle(article.id)
ElMessage.success('文章删除成功')
loadArticles()
} catch (error) {
if (error !== 'cancel') {
console.error('删除文章失败:', error)
}
}
}
// 编辑成功回调
const handleEditSuccess = () => {
loadArticles()
loadStats()
}
// 定时发布文章
const handleSchedulePublish = (article) => {
currentArticle.value = article
showScheduleDialog.value = true
}
// 定时发布成功回调
const handleScheduleSuccess = () => {
loadArticles()
loadStats()
}
// 取消定时发布
const handleCancelSchedule = async (article) => {
try {
await ElMessageBox.confirm('确定要取消这篇文章的定时发布吗?', '确认取消', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await articleApi.cancelSchedulePublishArticle(article.id)
ElMessage.success('取消定时发布成功')
loadArticles()
} catch (error) {
if (error !== 'cancel') {
console.error('取消定时发布失败:', error)
}
}
}
// 处理更多操作
const handleMoreAction = (command, article) => {
switch (command) {
case 'schedule-publish':
handleSchedulePublish(article)
break
case 'cancel-schedule':
handleCancelSchedule(article)
break
case 'view':
handleViewArticle(article)
break
case 'delete':
handleDeleteArticle(article)
break
default:
console.warn('未知的操作命令:', command)
}
}
// 分类管理对话框关闭回调
const handleCategoryDialogClose = () => {
loadCategories()
}
// 标签管理对话框关闭回调
const handleTagDialogClose = () => {
loadTags()
}
// 状态类型映射
const getStatusType = (status, scheduledAt) => {
if (status === 'draft' && scheduledAt) {
return 'warning' // 定时发布状态
}
const statusMap = {
draft: 'info',
published: 'success',
archived: 'warning'
}
return statusMap[status] || 'info'
}
// 状态文本映射
const getStatusText = (status, scheduledAt) => {
if (status === 'draft' && scheduledAt) {
return '定时发布'
}
const statusMap = {
draft: '草稿',
published: '已发布',
archived: '已归档'
}
return statusMap[status] || status
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 页面初始化
onMounted(() => {
loadArticles()
loadStats()
loadCategories()
loadTags()
})
</script>

View File

@@ -0,0 +1,187 @@
<template>
<div>
<!-- 页面头部 -->
<div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">文章标签管理</h1>
<p class="mt-1 text-sm text-gray-500">管理文章标签信息</p>
</div>
<div class="flex space-x-3">
<el-button type="primary" @click="handleCreateTag">
<el-icon class="mr-1"><PlusIcon /></el-icon>
新增标签
</el-button>
</div>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 标签列表 -->
<div>
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">标签列表</h3>
</div>
<el-table
v-loading="loading"
:data="tags"
stripe
class="w-full"
>
<el-table-column prop="name" label="标签名称" min-width="150">
<template #default="{ row }">
<div class="flex items-center">
<div
class="w-4 h-4 rounded mr-2"
:style="{ backgroundColor: row.color }"
></div>
<span class="font-medium text-gray-900">{{ row.name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="color" label="标签颜色" width="120">
<template #default="{ row }">
<div class="flex items-center">
<div
class="w-6 h-6 rounded mr-2"
:style="{ backgroundColor: row.color }"
></div>
<span class="text-gray-600">{{ row.color }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="article_count" label="文章数量" width="100" align="center">
<template #default="{ row }">
<span class="text-gray-600">{{ row.article_count || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">
<span class="text-gray-600">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<div class="flex space-x-2">
<el-button
type="primary"
size="small"
@click="handleEditTag(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDeleteTag(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 标签编辑对话框 -->
<TagEditDialog
v-model="showEditDialog"
:tag="currentTag"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup>
import { articleApi } from '@/api'
import { PlusIcon } from '@heroicons/vue/24/outline'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, ref } from 'vue'
import TagEditDialog from './components/TagEditDialog.vue'
// 响应式数据
const loading = ref(false)
const tags = ref([])
// 对话框控制
const showEditDialog = ref(false)
const currentTag = ref(null)
// 获取标签列表
const loadTags = async () => {
loading.value = true
try {
const response = await articleApi.getTags()
// 后端返回 { items, total },表格需要数组
tags.value = Array.isArray(response.data)
? response.data
: (response.data?.items || [])
} catch (error) {
ElMessage.error('获取标签列表失败')
console.error('获取标签列表失败:', error)
} finally {
loading.value = false
}
}
// 新增标签
const handleCreateTag = () => {
currentTag.value = null
showEditDialog.value = true
}
// 编辑标签
const handleEditTag = (tag) => {
currentTag.value = tag
showEditDialog.value = true
}
// 删除标签
const handleDeleteTag = async (tag) => {
try {
await ElMessageBox.confirm(
`确定要删除标签"${tag.name}"吗?删除后无法恢复!`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await articleApi.deleteTag(tag.id)
ElMessage.success('标签删除成功')
loadTags()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除标签失败')
console.error('删除标签失败:', error)
}
}
}
// 编辑成功回调
const handleEditSuccess = () => {
loadTags()
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
// 页面初始化
onMounted(() => {
loadTags()
})
</script>

View File

@@ -0,0 +1,307 @@
<template>
<ListPageLayout
title="分类管理"
subtitle="管理产品分类"
>
<template #actions>
<el-button type="primary" @click="handleCreateCategory">
新增分类
</el-button>
</template>
<template #table>
<el-table
v-loading="loading"
:data="categories"
stripe
class="w-full"
>
<el-table-column prop="code" label="分类编号" width="120" />
<el-table-column prop="name" label="分类名称" min-width="200" />
<el-table-column prop="description" label="分类描述" min-width="300" />
<el-table-column prop="sort" label="排序" width="120" />
<el-table-column prop="is_enabled" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="row.is_enabled ? 'success' : 'danger'" size="small">
{{ row.is_enabled ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_visible" label="展示" width="120">
<template #default="{ row }">
<el-tag :type="row.is_visible ? 'success' : 'warning'" size="small">
{{ row.is_visible ? '显示' : '隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="flex gap-2">
<el-button
type="primary"
size="small"
@click="handleEditCategory(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDeleteCategory(row)"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</template>
<template #extra>
<!-- 分类表单弹窗 -->
<el-dialog
v-model="formDialogVisible"
:title="isEdit ? '编辑分类' : '新增分类'"
width="600px"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="分类编号" prop="code">
<el-input
v-model="form.code"
placeholder="请输入分类编号"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="form.name"
placeholder="请输入分类名称"
/>
</el-form-item>
<el-form-item label="分类描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入分类描述"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="form.sort"
:min="0"
:max="999"
placeholder="排序值"
class="w-full"
/>
</el-form-item>
<el-form-item label="是否启用" prop="is_enabled">
<el-switch v-model="form.is_enabled" />
</el-form-item>
<el-form-item label="是否展示" prop="is_visible">
<el-switch v-model="form.is_visible" />
</el-form-item>
</el-form>
<template #footer>
<div class="flex justify-end gap-3">
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? '保存修改' : '创建分类' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
</ListPageLayout>
</template>
<script setup>
import { productAdminApi } from '@/api'
import ListPageLayout from '@/components/common/ListPageLayout.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
// 响应式数据
const loading = ref(false)
const categories = ref([])
const formDialogVisible = ref(false)
const submitting = ref(false)
const editingCategory = ref(null)
const formRef = ref(null)
// 表单初始值
const initialFormData = {
code: '',
name: '',
description: '',
sort: 0,
is_enabled: true,
is_visible: true
}
// 表单数据 - 严格按照后端CreateCategoryCommand和UpdateCategoryCommand的字段
const form = reactive({ ...initialFormData })
// 表单验证规则
const rules = {
code: [
{ required: true, message: '请输入分类编号', trigger: 'blur' },
{ min: 2, max: 20, message: '分类编号长度在 2 到 20 个字符', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 50, message: '分类名称长度在 2 到 50 个字符', trigger: 'blur' }
],
description: [
{ required: true, message: '请输入分类描述', trigger: 'blur' }
]
}
// 计算属性
const isEdit = computed(() => !!editingCategory.value)
// 初始化
onMounted(() => {
loadCategories()
})
// 加载分类列表
const loadCategories = async () => {
loading.value = true
try {
const response = await productAdminApi.getCategories({ page: 1, page_size: 10 })
categories.value = response.data?.items || []
} catch (error) {
console.error('加载分类失败:', error)
ElMessage.error('加载分类失败')
} finally {
loading.value = false
}
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('zh-CN')
}
// 新增分类
const handleCreateCategory = () => {
editingCategory.value = null
resetForm()
formDialogVisible.value = true
}
// 编辑分类
const handleEditCategory = (category) => {
editingCategory.value = { ...category }
Object.keys(form).forEach(key => {
if (category[key] !== undefined) {
form[key] = category[key]
}
})
formDialogVisible.value = true
}
// 删除分类
const handleDeleteCategory = async (category) => {
try {
await ElMessageBox.confirm(
`确定要删除分类"${category.name}"吗?此操作不可撤销。`,
'确认删除',
{
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning'
}
)
await productAdminApi.deleteCategory(category.id)
ElMessage.success('分类删除成功')
await loadCategories()
} catch (error) {
if (error !== 'cancel') {
console.error('删除分类失败:', error)
ElMessage.error('删除分类失败')
}
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
const submitData = { ...form }
if (isEdit.value) {
await productAdminApi.updateCategory(editingCategory.value.id, submitData)
ElMessage.success('分类更新成功')
} else {
await productAdminApi.createCategory(submitData)
ElMessage.success('分类创建成功')
}
formDialogVisible.value = false
await loadCategories()
} catch (error) {
if (error !== false) { // 不是表单验证错误
console.error('提交失败:', error)
ElMessage.error(isEdit.value ? '更新分类失败' : '创建分类失败')
}
} finally {
submitting.value = false
}
}
// 重置表单
const resetForm = () => {
// 先重置数据
form.code = ''
form.name = ''
form.description = ''
form.sort = 0
form.is_enabled = true
form.is_visible = true
// 然后清除表单验证状态
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate()
}
})
}
// 取消操作
const handleCancel = () => {
formDialogVisible.value = false
// 延迟重置,避免在弹窗关闭动画期间重置
setTimeout(() => {
resetForm()
editingCategory.value = null
}, 300)
}
</script>
<style scoped>
/* 页面特定样式可以在这里添加 */
</style>

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