f
9
.editorconfig
Normal 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
|
||||
3
.env
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_API_URL="https://api.haiyudata.com"
|
||||
VITE_CAPTCHA_SCENE_ID="wynt39to"
|
||||
VITE_CAPTCHA_ENCRYPTED_MODE=false
|
||||
418
.eslintrc-auto-import.json
Normal file
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"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,
|
||||
"buildTocFromMarkdown": 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,
|
||||
"encodeRequest": true,
|
||||
"endsWith": true,
|
||||
"escape": true,
|
||||
"extendRef": true,
|
||||
"filter": true,
|
||||
"find": true,
|
||||
"findIndex": true,
|
||||
"formatDate": true,
|
||||
"formatFileSize": true,
|
||||
"formatMoney": true,
|
||||
"formatPhone": true,
|
||||
"fromNow": true,
|
||||
"generateNonce": true,
|
||||
"generateSMSRequest": 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,
|
||||
"renderLegalMarkdown": 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,
|
||||
"useAliyunCaptcha": 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,
|
||||
"useMobileTable": 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,
|
||||
"useTimeAgoIntl": 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
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
30
.gitignore
vendored
Normal 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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
871
auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,871 @@
|
||||
/* 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/es')['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 buildTocFromMarkdown: typeof import('./src/utils/legalMarkdown.js')['buildTocFromMarkdown']
|
||||
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 encodeRequest: typeof import('./src/utils/smsSignature.js')['encodeRequest']
|
||||
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 generateNonce: typeof import('./src/utils/smsSignature.js')['generateNonce']
|
||||
const generateSMSRequest: typeof import('./src/utils/smsSignature.js')['generateSMSRequest']
|
||||
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 renderLegalMarkdown: typeof import('./src/utils/legalMarkdown.js')['renderLegalMarkdown']
|
||||
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 useAliyunCaptcha: typeof import('./src/composables/useAliyunCaptcha.js')['default']
|
||||
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 useMobileTable: typeof import('./src/composables/useMobileTable.js')['useMobileTable']
|
||||
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 useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl']
|
||||
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 buildTocFromMarkdown: UnwrapRef<typeof import('./src/utils/legalMarkdown.js')['buildTocFromMarkdown']>
|
||||
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 encodeRequest: UnwrapRef<typeof import('./src/utils/smsSignature.js')['encodeRequest']>
|
||||
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 generateNonce: UnwrapRef<typeof import('./src/utils/smsSignature.js')['generateNonce']>
|
||||
readonly generateSMSRequest: UnwrapRef<typeof import('./src/utils/smsSignature.js')['generateSMSRequest']>
|
||||
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 renderLegalMarkdown: UnwrapRef<typeof import('./src/utils/legalMarkdown.js')['renderLegalMarkdown']>
|
||||
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 useAliyunCaptcha: UnwrapRef<typeof import('./src/composables/useAliyunCaptcha.js')['default']>
|
||||
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 useMobileTable: UnwrapRef<typeof import('./src/composables/useMobileTable.js')['useMobileTable']>
|
||||
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 useTimeAgoIntl: UnwrapRef<typeof import('@vueuse/core')['useTimeAgoIntl']>
|
||||
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']>
|
||||
}
|
||||
}
|
||||
100
components.d.ts
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
/* 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']
|
||||
DanmakuBar: typeof import('./src/components/common/DanmakuBar.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']
|
||||
ElCascader: typeof import('element-plus/es')['ElCascader']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
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']
|
||||
ElSegmented: typeof import('element-plus/es')['ElSegmented']
|
||||
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']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
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']
|
||||
ResponsiveActionColumn: typeof import('./src/components/common/ResponsiveActionColumn.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']
|
||||
}
|
||||
}
|
||||
202
docs/API在线调试功能说明.md
Normal 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默认隐藏显示,可以点击"显示"按钮查看,建议不要泄露给他人。
|
||||
512
docs/API调用记录和钱包交易记录前端说明.md
Normal 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调用记录和钱包交易记录前端页面提供了完整的用户界面和交互体验,通过合理的组件设计、状态管理和样式优化,确保了良好的用户体验和系统性能。页面支持响应式设计,适配不同设备,并提供了丰富的筛选和查看功能。
|
||||
164
docs/产品API配置管理功能说明.md
Normal 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": "2026-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 hyapi-server
|
||||
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文档
|
||||
196
docs/充值功能前端说明.md
Normal 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: 'TR202612010001',
|
||||
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. **响应式** - 确保在不同设备上的良好显示效果
|
||||
295
docs/支付宝充值功能说明.md
Normal 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": "202612011234567890",
|
||||
"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.haiyudata.com/api/v1/finance/alipay/callback"
|
||||
return_url: "https://console.haiyudata.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
@@ -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,
|
||||
])
|
||||
22
index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!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>
|
||||
<!-- 阿里云滑块验证码 -->
|
||||
<script>
|
||||
window.AliyunCaptchaConfig = { region: "cn", prefix: "12zxnj" };
|
||||
</script>
|
||||
<script
|
||||
type="text/javascript"
|
||||
src="https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="captcha-element"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
62
package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "hyapi-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",
|
||||
"color4bg": "^0.1.1",
|
||||
"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",
|
||||
"qrcode": "^1.5.4",
|
||||
"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"
|
||||
}
|
||||
5255
pnpm-lock.yaml
generated
Normal file
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 249 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
282
public/examples/csharp/demo.cs
Normal 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.haiyudata.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
@@ -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.haiyudata.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)
|
||||
}
|
||||
}
|
||||
129
public/examples/java/demo.java
Normal 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.haiyudata.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
266
public/examples/javascript/sms_signature_demo.js
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 短信发送接口签名示例(浏览器版本)
|
||||
*
|
||||
* 本示例演示如何在浏览器中为短信发送接口生成HMAC-SHA256签名
|
||||
*
|
||||
* 安全提示:
|
||||
* 1. 密钥应该通过代码混淆、字符串拆分等方式隐藏
|
||||
* 2. 不要在前端代码中直接暴露完整密钥
|
||||
* 3. 建议使用构建工具进行代码混淆和压缩
|
||||
* 4. 可以考虑将签名逻辑放在后端代理接口中
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取签名密钥(通过多种方式混淆,增加破解难度)
|
||||
* 注意:这只是示例,实际使用时应该进一步混淆
|
||||
*/
|
||||
function getSecretKey() {
|
||||
// 方式1: 字符串拆分和拼接
|
||||
const part1 = 'HyApi2026'
|
||||
const part2 = 'SMSSecret'
|
||||
const part3 = 'Key!@#$%^'
|
||||
const part4 = '&*()_+QWERTY'
|
||||
const part5 = 'UIOP'
|
||||
|
||||
// 方式2: 使用数组和join(增加混淆)
|
||||
const arr = [part1, part2, part3, part4, part5]
|
||||
return arr.join('')
|
||||
|
||||
// 方式3: 字符数组拼接(更复杂的方式)
|
||||
// const chars = ['T', 'y', 'A', 'p', 'i', '2', '0', '2', '4', ...];
|
||||
// return chars.join('');
|
||||
|
||||
// 方式4: 使用atob解码(如果密钥经过base64编码)
|
||||
// const encoded = 'base64_encoded_string';
|
||||
// return atob(encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串(用于nonce)
|
||||
*/
|
||||
function generateNonce(length = 16) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let result = ''
|
||||
const array = new Uint8Array(length)
|
||||
crypto.getRandomValues(array)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[array[i] % chars.length]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用Web Crypto API生成HMAC-SHA256签名
|
||||
*
|
||||
* @param {Object} params - 请求参数对象
|
||||
* @param {string} secretKey - 签名密钥
|
||||
* @param {number} timestamp - 时间戳(秒)
|
||||
* @param {string} nonce - 随机字符串
|
||||
* @returns {Promise<string>} 签名字符串(hex编码)
|
||||
*/
|
||||
async function generateSignature(params, secretKey, timestamp, nonce) {
|
||||
// 1. 构建待签名字符串:按key排序,拼接成 key1=value1&key2=value2 格式
|
||||
const keys = Object.keys(params)
|
||||
.filter((k) => k !== 'signature') // 排除签名字段
|
||||
.sort()
|
||||
|
||||
const parts = keys.map((k) => `${k}=${params[k]}`)
|
||||
|
||||
// 2. 添加时间戳和随机数
|
||||
parts.push(`timestamp=${timestamp}`)
|
||||
parts.push(`nonce=${nonce}`)
|
||||
|
||||
// 3. 拼接成待签名字符串
|
||||
const signString = parts.join('&')
|
||||
|
||||
// 4. 使用Web Crypto API计算HMAC-SHA256签名
|
||||
const encoder = new TextEncoder()
|
||||
const keyData = encoder.encode(secretKey)
|
||||
const messageData = encoder.encode(signString)
|
||||
|
||||
// 导入密钥
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
|
||||
// 计算签名
|
||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData)
|
||||
|
||||
// 转换为hex字符串
|
||||
const hashArray = Array.from(new Uint8Array(signature))
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
return hashHex
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义编码字符集(与后端保持一致)
|
||||
*/
|
||||
const CUSTOM_ENCODE_CHARSET =
|
||||
'0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
|
||||
/**
|
||||
* 自定义Base64编码(使用自定义字符集)
|
||||
*/
|
||||
function customBase64Encode(data) {
|
||||
if (data.length === 0) return ''
|
||||
|
||||
// 将字符串转换为UTF-8字节数组
|
||||
const encoder = new TextEncoder()
|
||||
const bytes = encoder.encode(data)
|
||||
const charset = CUSTOM_ENCODE_CHARSET
|
||||
let result = ''
|
||||
|
||||
// 将3个字节(24位)编码为4个字符
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b1 = bytes[i]
|
||||
const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||
const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||
|
||||
// 组合成24位
|
||||
const combined = (b1 << 16) | (b2 << 8) | b3
|
||||
|
||||
// 分成4个6位段
|
||||
result += charset[(combined >> 18) & 0x3f]
|
||||
result += charset[(combined >> 12) & 0x3f]
|
||||
|
||||
if (i + 1 < bytes.length) {
|
||||
result += charset[(combined >> 6) & 0x3f]
|
||||
} else {
|
||||
result += '=' // 填充字符
|
||||
}
|
||||
|
||||
if (i + 2 < bytes.length) {
|
||||
result += charset[combined & 0x3f]
|
||||
} else {
|
||||
result += '=' // 填充字符
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用字符偏移混淆
|
||||
*/
|
||||
function applyCharShift(data, shift) {
|
||||
const charset = CUSTOM_ENCODE_CHARSET
|
||||
const charsetLen = charset.length
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const c = data[i]
|
||||
if (c === '=') {
|
||||
result += c // 填充字符不变
|
||||
continue
|
||||
}
|
||||
|
||||
const idx = charset.indexOf(c)
|
||||
if (idx === -1) {
|
||||
result += c // 不在字符集中,保持不变
|
||||
} else {
|
||||
// 应用偏移
|
||||
const newIdx = (idx + shift) % charsetLen
|
||||
result += charset[newIdx]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义编码请求数据
|
||||
*/
|
||||
function encodeRequest(data) {
|
||||
// 1. 使用自定义Base64编码
|
||||
const encoded = customBase64Encode(data)
|
||||
|
||||
// 2. 应用字符偏移混淆(偏移7个位置)
|
||||
const confused = applyCharShift(encoded, 7)
|
||||
|
||||
return confused
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码(带签名)- 方式2:编码后传输(推荐,更安全)
|
||||
* 将所有参数(包括签名)使用自定义编码方案编码后传输,隐藏参数结构
|
||||
*
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} scene - 场景(register/login/change_password/reset_password等)
|
||||
* @param {string} apiBaseUrl - API基础URL
|
||||
* @returns {Promise<Object>} 响应结果
|
||||
*/
|
||||
async function sendSMSWithEncodedSignature(phone, scene, apiBaseUrl = 'http://localhost:8080') {
|
||||
// 1. 准备参数
|
||||
const timestamp = Math.floor(Date.now() / 1000) // 当前时间戳(秒)
|
||||
const nonce = generateNonce(16) // 生成随机字符串
|
||||
|
||||
const params = {
|
||||
phone: phone,
|
||||
scene: scene,
|
||||
}
|
||||
|
||||
// 2. 生成签名
|
||||
const secretKey = getSecretKey()
|
||||
const signature = await generateSignature(params, secretKey, timestamp, nonce)
|
||||
|
||||
// 3. 构建包含所有参数的JSON对象
|
||||
const allParams = {
|
||||
phone: phone,
|
||||
scene: scene,
|
||||
timestamp: timestamp,
|
||||
nonce: nonce,
|
||||
signature: signature,
|
||||
}
|
||||
|
||||
// 4. 将JSON对象转换为字符串,然后使用自定义编码方案编码
|
||||
const jsonString = JSON.stringify(allParams)
|
||||
const encodedData = encodeRequest(jsonString)
|
||||
|
||||
// 5. 构建请求体(只包含编码后的data字段)
|
||||
const requestBody = {
|
||||
data: encodedData,
|
||||
}
|
||||
|
||||
// 6. 发送请求
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/users/send-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('发送短信失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 如果在浏览器环境中使用,可以导出到全局
|
||||
if (typeof window !== 'undefined') {
|
||||
window.SMSSignature = {
|
||||
sendSMSWithEncodedSignature,
|
||||
generateSignature,
|
||||
generateNonce,
|
||||
encodeRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// 如果在Node.js环境中使用(需要安装crypto-js等库)
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
sendSMSWithEncodedSignature,
|
||||
generateSignature,
|
||||
generateNonce,
|
||||
getSecretKey,
|
||||
encodeRequest,
|
||||
}
|
||||
}
|
||||
11
public/examples/nodejs/.eslintignore
Normal file
@@ -0,0 +1,11 @@
|
||||
# ESLint 忽略配置 - 忽略示例代码目录下的所有语法错误
|
||||
# 这些是示例代码文件,不需要进行 ESLint 语法检查
|
||||
|
||||
# 忽略当前目录下的所有 .js 文件
|
||||
*.js
|
||||
|
||||
# 忽略当前目录下的所有文件
|
||||
*
|
||||
|
||||
# 忽略子目录
|
||||
*/
|
||||
131
public/examples/nodejs/demo.js
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/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.haiyudata.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()
|
||||
287
public/examples/nodejs/sms_signature_demo.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 短信发送接口签名示例
|
||||
*
|
||||
* 本示例演示如何为短信发送接口生成HMAC-SHA256签名
|
||||
*
|
||||
* 安全提示:
|
||||
* 1. 密钥应该通过代码混淆、字符串拆分等方式隐藏
|
||||
* 2. 不要在前端代码中直接暴露完整密钥
|
||||
* 3. 建议使用构建工具进行代码混淆
|
||||
*/
|
||||
|
||||
const crypto = require('crypto')
|
||||
|
||||
/**
|
||||
* 获取签名密钥(通过多种方式混淆,增加破解难度)
|
||||
* 注意:这只是示例,实际使用时应该进一步混淆
|
||||
*/
|
||||
function getSecretKey() {
|
||||
// 方式1: 字符串拆分和拼接
|
||||
const part1 = 'HyApi2026'
|
||||
const part2 = 'SMSSecret'
|
||||
const part3 = 'Key!@#$%^'
|
||||
const part4 = '&*()_+QWERTY'
|
||||
const part5 = 'UIOP'
|
||||
|
||||
// 方式2: Base64解码(可选,增加一层混淆)
|
||||
// const encoded = Buffer.from('some_base64_string', 'base64').toString();
|
||||
|
||||
// 方式3: 字符数组拼接
|
||||
const chars = [
|
||||
'T',
|
||||
'y',
|
||||
'A',
|
||||
'p',
|
||||
'i',
|
||||
'2',
|
||||
'0',
|
||||
'2',
|
||||
'4',
|
||||
'S',
|
||||
'M',
|
||||
'S',
|
||||
'S',
|
||||
'e',
|
||||
'c',
|
||||
'r',
|
||||
'e',
|
||||
't',
|
||||
'K',
|
||||
'e',
|
||||
'y',
|
||||
'!',
|
||||
'@',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'^',
|
||||
'&',
|
||||
'*',
|
||||
'(',
|
||||
')',
|
||||
'_',
|
||||
'+',
|
||||
'Q',
|
||||
'W',
|
||||
'E',
|
||||
'R',
|
||||
'T',
|
||||
'Y',
|
||||
'U',
|
||||
'I',
|
||||
'O',
|
||||
'P',
|
||||
]
|
||||
const fromChars = chars.join('')
|
||||
|
||||
// 组合多种方式(实际密钥:HyApi2026SMSSecretKey!@#$%^&*()_+QWERTYUIOP)
|
||||
return part1 + part2 + part3 + part4 + part5
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机字符串(用于nonce)
|
||||
*/
|
||||
function generateNonce(length = 16) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成HMAC-SHA256签名
|
||||
*
|
||||
* @param {Object} params - 请求参数对象
|
||||
* @param {string} secretKey - 签名密钥
|
||||
* @param {number} timestamp - 时间戳(秒)
|
||||
* @param {string} nonce - 随机字符串
|
||||
* @returns {string} 签名字符串(hex编码)
|
||||
*/
|
||||
function generateSignature(params, secretKey, timestamp, nonce) {
|
||||
// 1. 构建待签名字符串:按key排序,拼接成 key1=value1&key2=value2 格式
|
||||
const keys = Object.keys(params)
|
||||
.filter((k) => k !== 'signature') // 排除签名字段
|
||||
.sort()
|
||||
|
||||
const parts = keys.map((k) => `${k}=${params[k]}`)
|
||||
|
||||
// 2. 添加时间戳和随机数
|
||||
parts.push(`timestamp=${timestamp}`)
|
||||
parts.push(`nonce=${nonce}`)
|
||||
|
||||
// 3. 拼接成待签名字符串
|
||||
const signString = parts.join('&')
|
||||
|
||||
// 4. 使用HMAC-SHA256计算签名
|
||||
const signature = crypto.createHmac('sha256', secretKey).update(signString).digest('hex')
|
||||
|
||||
return signature
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义编码字符集(与后端保持一致)
|
||||
*/
|
||||
const CUSTOM_ENCODE_CHARSET =
|
||||
'0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
|
||||
/**
|
||||
* 自定义Base64编码(使用自定义字符集)
|
||||
*/
|
||||
function customBase64Encode(data) {
|
||||
if (data.length === 0) return ''
|
||||
|
||||
const bytes = Buffer.from(data, 'utf8')
|
||||
const charset = CUSTOM_ENCODE_CHARSET
|
||||
let result = ''
|
||||
|
||||
// 将3个字节(24位)编码为4个字符
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b1 = bytes[i]
|
||||
const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0
|
||||
const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0
|
||||
|
||||
// 组合成24位
|
||||
const combined = (b1 << 16) | (b2 << 8) | b3
|
||||
|
||||
// 分成4个6位段
|
||||
result += charset[(combined >> 18) & 0x3f]
|
||||
result += charset[(combined >> 12) & 0x3f]
|
||||
|
||||
if (i + 1 < bytes.length) {
|
||||
result += charset[(combined >> 6) & 0x3f]
|
||||
} else {
|
||||
result += '=' // 填充字符
|
||||
}
|
||||
|
||||
if (i + 2 < bytes.length) {
|
||||
result += charset[combined & 0x3f]
|
||||
} else {
|
||||
result += '=' // 填充字符
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用字符偏移混淆
|
||||
*/
|
||||
function applyCharShift(data, shift) {
|
||||
const charset = CUSTOM_ENCODE_CHARSET
|
||||
const charsetLen = charset.length
|
||||
let result = ''
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const c = data[i]
|
||||
if (c === '=') {
|
||||
result += c // 填充字符不变
|
||||
continue
|
||||
}
|
||||
|
||||
const idx = charset.indexOf(c)
|
||||
if (idx === -1) {
|
||||
result += c // 不在字符集中,保持不变
|
||||
} else {
|
||||
// 应用偏移
|
||||
const newIdx = (idx + shift) % charsetLen
|
||||
result += charset[newIdx]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义编码请求数据
|
||||
*/
|
||||
function encodeRequest(data) {
|
||||
// 1. 使用自定义Base64编码
|
||||
const encoded = customBase64Encode(data)
|
||||
|
||||
// 2. 应用字符偏移混淆(偏移7个位置)
|
||||
const confused = applyCharShift(encoded, 7)
|
||||
|
||||
return confused
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码(带签名)- 方式2:编码后传输(推荐,更安全)
|
||||
* 将所有参数(包括签名)使用自定义编码方案编码后传输,隐藏参数结构
|
||||
*
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} scene - 场景(register/login/change_password/reset_password等)
|
||||
* @param {string} apiBaseUrl - API基础URL
|
||||
* @returns {Promise<Object>} 响应结果
|
||||
*/
|
||||
async function sendSMSWithEncodedSignature(phone, scene, apiBaseUrl = 'http://localhost:8080') {
|
||||
// 1. 准备参数
|
||||
const timestamp = Math.floor(Date.now() / 1000) // 当前时间戳(秒)
|
||||
const nonce = generateNonce(16) // 生成随机字符串
|
||||
|
||||
const params = {
|
||||
phone: phone,
|
||||
scene: scene,
|
||||
}
|
||||
|
||||
// 2. 生成签名
|
||||
const secretKey = getSecretKey()
|
||||
const signature = generateSignature(params, secretKey, timestamp, nonce)
|
||||
|
||||
// 3. 构建包含所有参数的JSON对象
|
||||
const allParams = {
|
||||
phone: phone,
|
||||
scene: scene,
|
||||
timestamp: timestamp,
|
||||
nonce: nonce,
|
||||
signature: signature,
|
||||
}
|
||||
|
||||
// 4. 将JSON对象转换为字符串,然后使用自定义编码方案编码
|
||||
const jsonString = JSON.stringify(allParams)
|
||||
const encodedData = encodeRequest(jsonString)
|
||||
|
||||
// 5. 构建请求体(只包含编码后的data字段)
|
||||
const requestBody = {
|
||||
data: encodedData,
|
||||
}
|
||||
|
||||
// 6. 发送请求
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/users/send-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('发送短信失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
if (require.main === module) {
|
||||
console.log('=== 发送短信验证码(使用自定义编码) ===')
|
||||
// 示例:发送注册验证码(使用自定义编码方案,只传递data字段)
|
||||
sendSMSWithEncodedSignature('13800138000', 'register', 'http://localhost:8080')
|
||||
.then((result) => {
|
||||
console.log('发送成功:', result)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('发送失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendSMSWithEncodedSignature,
|
||||
generateSignature,
|
||||
generateNonce,
|
||||
getSecretKey,
|
||||
encodeRequest,
|
||||
}
|
||||
176
public/examples/php/demo.php
Normal 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.haiyudata.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();
|
||||
?>
|
||||
124
public/examples/python/demo.py
Normal 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.haiyudata.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-16x16.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
61
public/legal/yhxy.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 海宇数据用户协议
|
||||
|
||||
## 欢迎与提示
|
||||
|
||||
欢迎访问海宇数据网站并使用我们提供的产品和服务。在完成注册程序或以任何方式使用海宇数据网站服务前,请您务必仔细阅读并透彻理解本服务条款,在确认充分理解后选择接受或不接受本服务条款。一旦您完成「同意条款并注册」或开始以其他方式使用海宇数据服务,即表明您已阅读并同意受本服务条款的约束。如您不同意本服务条款或其中任何条款约定,您应不再进行下一步或停止注册程序。
|
||||
|
||||
海宇数据再次提示您审慎阅读、充分理解各条款内容,特别是**限制或免除责任**的相应条款。限制或免除责任条款将以加粗或其他醒目形式提示您注意。如果您未满 18 周岁,请在法定监护人的陪同下阅读本服务条款。海宇数据致力于为用户提供优质的服务,同时严格遵守相关的法律法规,保护您的合法权益,确保您在使用我们的服务时能够享受到安全、便捷、高效的体验。我们始终将用户的需求和体验放在首位,通过不断优化和升级我们的服务,力求为用户提供全方位的支持和保障。我们注重用户的反馈和建议,积极进行服务的改进与创新,力求打造行业领先的数据服务平台。
|
||||
|
||||
## 一、签约主体及协议范围
|
||||
|
||||
### 1.1 协议主体
|
||||
|
||||
本服务协议是您与**海宇数科(广东横琴)科技有限公司**(以下简称「海宇数据」或「我们」)就使用海宇数据网站服务(包括提供的网页浏览、账户注册、数据查询、数据分析、API 接口调用等服务)所达成的有效合约。您通过访问和使用海宇数据的服务,表示您同意与海宇数科(广东横琴)科技有限公司订立此具有法律约束力的协议。我们致力于为用户提供高质量的数据服务,并以合规和透明的方式进行运营,确保用户的使用体验。
|
||||
|
||||
### 1.2 服务载体与具体服务
|
||||
|
||||
海宇数据网站包含域名为 [www.haiyudata.com](https://www.haiyudata.com) 的网站以及相关移动客户端(例如 APP)。如您使用或购买海宇数据网站上的某项具体服务,您可能仍需确认具体服务对应的服务条款。每项具体服务可能会包含不同的使用条款、收费标准和服务时间,建议您在使用前仔细查阅相关的协议条款。我们可能根据实际情况对服务内容进行变更,并将尽力以电子邮件、网站公告等形式通知您。若您未能在通知的期限内明确表示异议,您将被视为同意相关变更。我们将尽一切可能确保变更后的条款不影响您的基本权利和服务体验,并提供合理的过渡期以减少对用户的影响。
|
||||
|
||||
### 1.3 与单项协议的关系
|
||||
|
||||
本协议包括但不限于服务条款、隐私政策、使用说明、用户行为规范等,所有这些条款共同构成您与海宇数据之间的完整协议。若您与海宇数据之间存在其他书面协议或在线确认的条款,除非这些条款与本协议存在直接冲突,否则应共同适用于您对海宇数据服务的使用。我们建议用户在使用不同服务前,仔细阅读与其对应的具体条款,以全面了解各项服务的要求和规范,避免在服务使用中产生误解或纠纷。同时,我们承诺不断完善服务协议内容,以更好地保护用户的合法权益。
|
||||
|
||||
## 二、账户注册、使用及安全
|
||||
|
||||
### 2.1 注册资格
|
||||
|
||||
#### 2.1.1 行为能力及未成年人
|
||||
|
||||
您确认,您应具备完全民事行为能力的自然人、法人或其他组织。如您是未成年人或限制民事行为能力人,您的监护人应承担因您的不当注册行为而导致的一切后果。若您不具备前述条件,您应立即停止账户注册程序或停止使用海宇数据提供的服务。为了保障用户的合法权益,我们可能要求您提供相关的身份证明材料,以验证您的注册资格。在用户资格验证过程中,我们承诺对用户的隐私信息进行严格保护,确保不会泄露或滥用您的身份信息。
|
||||
|
||||
#### 2.1.2 出口管制与制裁
|
||||
|
||||
您还需确保自己不是任何国家、国际组织或地域实施的贸易限制、制裁或其他法律、规则限制的对象,否则可能无法正常注册和使用海宇数据服务。在此情况下,海宇数据保留取消您的注册和服务使用权限的权利,以保障公司及其他用户的合法权益不受损害。我们承诺将根据相关法律法规的要求严格保密您的个人信息,绝不将其用于与注册资格验证无关的用途。如因违反此条款导致的任何法律后果,您需自行承担全部责任。
|
||||
|
||||
### 2.2 账户注册
|
||||
|
||||
#### 2.2.1 注册成为用户
|
||||
|
||||
当您按照注册页面提示填写信息、阅读并同意本协议且完成注册程序后,您即可获得海宇数据账户并成为海宇数据的用户。我们将对您提供的注册信息予以核实,确保其真实性和有效性,并保留进一步联系以验证信息的权利。对于提供虚假信息的用户,海宇数据有权随时终止服务,并保留追究其法律责任的权利。我们建议用户提供真实、准确、完整的个人信息,以便更好地享受我们的服务。
|
||||
|
||||
#### 2.2.2 账户名称与密码
|
||||
|
||||
您在注册时设置的账户名称及密码,将用于后续登录和使用服务,请务必妥善保管。您应对您的账户信息保密,不得泄露给任何其他人,以防账户被他人恶意使用。因账户信息泄露或因您的疏忽导致账户被盗用所产生的后果由您自行承担。为了提高账户安全性,我们建议您定期更换密码,并避免使用过于简单或容易猜测的密码。同时,您可以启用双重身份验证功能以增加账户的安全性,进一步防范账户被盗风险。
|
||||
|
||||
#### 2.2.3 信息真实、准确与更新
|
||||
|
||||
您需确保账户信息的真实性和准确性,并及时更新。如您提供的信息不准确、不真实或未能及时更新,海宇数据有权采取措施限制您的使用权限,包括但不限于暂停或注销您的账户。若因您提供虚假信息导致纠纷,您应承担全部责任,同时海宇数据有权拒绝为您提供后续的服务。为了确保账户信息的真实性和安全性,我们将定期对账户信息进行核验,并可能要求您在特定情况下重新验证您的身份。对于多次提供虚假信息或拒绝验证的用户,我们将保留终止其账户服务的权利。
|
||||
|
||||
#### 2.2.4 进一步身份核验
|
||||
|
||||
海宇数据可能要求您提供更多身份信息以使用特定服务,账户在验证通过后方可获得相关使用资格。我们将根据相关法律法规的规定对您的信息进行严格保护,不会用于协议目的之外的用途。对于涉及特定敏感数据的服务,我们可能采取更严格的验证措施以确保数据安全与合规。例如,涉及金融数据或涉及高风险业务的服务,我们将要求用户提供额外的身份证明,以确保服务的合法性和安全性。我们还可能会对某些高敏感度的数据进行双重验证,以确保数据的绝对安全性和服务的合规性。对于未能通过验证的用户,我们将无法提供相关服务,敬请谅解。
|
||||
|
||||
### 2.3 账户安全
|
||||
|
||||
#### 2.3.1 保管与退出
|
||||
|
||||
您的账户和密码由您自行设置并由您保管,您应对因账户保管不善导致的任何损失负责。您需确保在每次上网时段结束时正确退出,确保账户安全,避免因公共网络或设备使用不当导致账户泄露。为保护账户安全,我们建议您不要在公共网络环境中使用海宇数据服务,并定期检查账户的登录记录。若发现任何异常登录活动,应及时更改密码并联系海宇数据的客服人员以获得帮助。
|
||||
|
||||
#### 2.3.2 通知与配合
|
||||
|
||||
若您发现账户被未经授权使用或存在其他安全问题,请立即通过海宇数据官方公布的联系方式通知我们,并配合我们采取为核实身份及保护账户安全所合理必需的措施。
|
||||
57
public/legal/yszc.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 海宇数据隐私政策
|
||||
|
||||
**海宇数科(广东横琴)科技有限公司**(以下简称「海宇数据」或「我们」)非常重视您的隐私保护。您在使用我们的服务时,我们可能会收集和使用您的个人信息。本隐私政策旨在向您说明,我们如何收集、使用、储存和共享这些信息,以及如何为您提供访问、更正、控制和保护这些信息的方式。我们致力于维护您的个人信息的隐私、安全与保密性。请您仔细阅读本政策,确保您在充分理解的基础上使用我们的服务。使用或继续使用我们的服务,即表示您已充分理解并同意本政策的全部内容。
|
||||
|
||||
我们深知个人信息对于您的重要性,因此会采取一切合理、可行的措施来保护您的个人信息安全。我们秉承透明、合法、必要的原则进行信息的收集和处理,以最大限度地保障您的合法权益。此外,我们不断更新和改进我们的安全技术和管理措施,以应对不断变化的信息安全威胁,确保您的信息始终处于安全状态。
|
||||
|
||||
## 目录
|
||||
|
||||
1. 我们收集哪些信息及如何使用这些信息
|
||||
2. 我们如何使用 Cookie 和类似技术
|
||||
3. 我们如何共享、转让及公开披露您的个人信息
|
||||
4. 我们如何保存和保护您的个人信息
|
||||
5. 您的权利
|
||||
6. 未成年人的信息保护
|
||||
7. 个人信息的跨境传输
|
||||
8. 本政策的更新
|
||||
9. 如何联系我们
|
||||
|
||||
## 一、我们收集哪些信息及如何使用这些信息
|
||||
|
||||
### 1.1 个人信息的定义
|
||||
|
||||
「个人信息」是指以电子或其他方式记录的、能够单独或与其他信息结合识别您身份的各种信息。
|
||||
这些信息可能包括但不限于:姓名、手机号码、身份证号码、地址、位置信息、支付信息、电子邮箱、账户信息、设备信息以及其他能够识别您身份的相关数据。我们收集这些信息的目的是为了为您提供更为个性化、高效和便捷的服务。
|
||||
|
||||
### 1.2 信息的收集方式
|
||||
|
||||
#### (1)您主动提供的信息
|
||||
|
||||
在您使用我们的服务时,我们会收集您主动提供的个人信息。具体包括:
|
||||
|
||||
- **注册和身份验证信息**:当您注册成为我们的用户时,您可能需要提供您的账号名称、手机号码、电子邮箱、登录密码、真实姓名、身份证号码等,以便我们为您提供更为个性化的服务。
|
||||
- **支付信息**:在使用我们的服务过程中,您可能会提供支付相关信息,如银行卡信息、交易记录、开票信息等。这些信息将用于处理支付、结算以及可能的退款。我们会确保这些信息通过加密手段进行传输和存储,以保障您的财产安全。
|
||||
- **位置信息**:当您开启设备的定位功能后,我们会通过 GPS、Wi-Fi 等技术获取您的位置信息,以便为您提供与地理位置相关的个性化服务,如附近的服务推荐、路线规划等。我们尊重您的选择,您可以通过设备设置管理是否授权我们获取您的位置信息。
|
||||
- **用户提供的内容**:您可以在我们的平台上发布评论、上传图片及其他内容,我们将收集这些您主动发布的信息,以便更好地满足您与其他用户的互动需求。我们也会分析这些信息以优化平台的用户体验。
|
||||
|
||||
#### (2)我们自动采集的信息
|
||||
|
||||
在您使用我们的服务过程中,我们会通过技术手段自动采集某些信息。具体包括:
|
||||
|
||||
- **设备信息**:为了优化我们的服务体验,我们会收集设备相关的信息,包括但不限于设备型号、操作系统、设备唯一标识符(如 IMEI)、IP 地址、浏览器类型等。这些信息有助于我们更好地适配我们的服务,并确保不同设备上的用户都能够获得一致的使用体验。
|
||||
- **日志信息**:每当您访问我们的服务时,我们会自动记录访问的时间、浏览页面、点击行为、操作记录、崩溃日志等,以帮助我们更好地分析用户行为并改进服务质量。我们会将这些信息用于统计分析,以便不断改进我们的产品和服务,使其更符合用户的需求。
|
||||
|
||||
#### (3)第三方信息
|
||||
|
||||
- **第三方授权登录**:当您通过第三方账号(如微信、QQ)登录时,我们会根据您的授权从第三方获取必要的个人信息,包括昵称、头像、绑定的电话号码等,以便我们为您提供快速登录和便捷的服务体验。我们会确保获取的信息仅用于您授权的用途,并严格遵守第三方平台的相关规定。
|
||||
- **关联公司和合作伙伴提供的信息**:在获得您的明确授权后,我们可能从合作伙伴处获取相关信息,用于帮助我们更好地理解您的需求并为您提供更全面的服务。例如,我们可能从合作伙伴处获取您在其他平台的相关服务数据,以便为您提供整合的服务和用户体验。
|
||||
|
||||
#### (4)敏感个人信息
|
||||
|
||||
敏感个人信息是指一旦泄露、非法提供或滥用,可能对您的人身安全、财产安全、个人名誉、身体健康或其他方面造成损害的个人信息。常见的敏感信息包括身份证号、银行账户信息、健康数据、位置信息等。我们在收集和使用这些敏感信息时会特别谨慎,并采取严格的保护措施,确保此类信息的安全性。例如,我们在收集您的身份信息时,会使用安全的加密技术,防止未经授权的访问和使用。
|
||||
|
||||
## 二、我们如何使用 Cookie 和类似技术
|
||||
|
||||
为了能够更好地为您提供个性化的服务体验,我们和第三方合作伙伴会通过 Cookies、标签(Pixel Tags)等技术收集和存储您的信息,具体用途包括:
|
||||
|
||||
- **身份验证**:帮助您在使用我们的服务时保持登录状态,以便您无需重复输入账户和密码,从而节省您的时间并提升您的使用体验。
|
||||
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
public/qrcode.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
1
public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
||||
{"background_color":"#ffffff","display":"standalone","icons":[{"sizes":"192x192","src":"/android-chrome-192x192.png","type":"image/png"},{"sizes":"512x512","src":"/android-chrome-512x512.png","type":"image/png"}],"name":"","short_name":"","theme_color":"#ffffff"}
|
||||
93
public/zh_CN.js
Normal 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 或 Linux:Alt+F9</dd>\n' +
|
||||
' <dd>macOS:⌥F9</dd>\n' +
|
||||
' <dt>使工具栏处于焦点</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F10</dd>\n' +
|
||||
' <dd>macOS:⌥F10</dd>\n' +
|
||||
' <dt>使页脚处于焦点</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F11</dd>\n' +
|
||||
' <dd>macOS:⌥F11</dd>\n' +
|
||||
' <dt>使通知处于焦点</dt>\n' +
|
||||
' <dd>Windows 或 Linux:Alt+F12</dd>\n' +
|
||||
' <dd>macOS:⌥F12</dd>\n' +
|
||||
' <dt>使上下文工具栏处于焦点</dt>\n' +
|
||||
' <dd>Windows、Linux 或 macOS:Ctrl+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
@@ -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>
|
||||
28
src/api/announcement.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 公告管理API
|
||||
export const announcementApi = {
|
||||
// ==================== 用户端API ====================
|
||||
// 公告查询
|
||||
getAnnouncements: (params) => request.get('/announcements', { params }),
|
||||
getAnnouncementDetail: (id) => request.get(`/announcements/${id}`),
|
||||
|
||||
// ==================== 管理员端API ====================
|
||||
// 统计信息
|
||||
getAnnouncementStats: () => request.get('/admin/announcements/stats'),
|
||||
|
||||
// 公告管理
|
||||
getAnnouncementsForAdmin: (params) => request.get('/admin/announcements', { params }),
|
||||
createAnnouncement: (data) => request.post('/admin/announcements', data),
|
||||
updateAnnouncement: (id, data) => request.put(`/admin/announcements/${id}`, data),
|
||||
deleteAnnouncement: (id) => request.delete(`/admin/announcements/${id}`),
|
||||
|
||||
// 公告状态管理
|
||||
publishAnnouncement: (id) => request.post(`/admin/announcements/${id}/publish`),
|
||||
withdrawAnnouncement: (id) => request.post(`/admin/announcements/${id}/withdraw`),
|
||||
archiveAnnouncement: (id) => request.post(`/admin/announcements/${id}/archive`),
|
||||
schedulePublishAnnouncement: (id, data) => request.post(`/admin/announcements/${id}/schedule-publish`, data),
|
||||
updateSchedulePublishAnnouncement: (id, data) => request.post(`/admin/announcements/${id}/update-schedule-publish`, data),
|
||||
cancelSchedulePublishAnnouncement: (id) => request.post(`/admin/announcements/${id}/cancel-schedule`),
|
||||
}
|
||||
|
||||
45
src/api/article.js
Normal 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}`)
|
||||
}
|
||||
56
src/api/balanceAlertApi.js
Normal 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
|
||||
}
|
||||
381
src/api/index.js
Normal file
@@ -0,0 +1,381 @@
|
||||
import request from '@/utils/request'
|
||||
import { announcementApi } from './announcement.js'
|
||||
import { articleApi } from './article.js'
|
||||
import { balanceAlertApi } from './balanceAlertApi.js'
|
||||
import { adminInvoiceApi, invoiceApi } from './invoice.js'
|
||||
|
||||
// 直接导出发票API、文章API、公告API和余额预警API
|
||||
export { adminInvoiceApi, announcementApi, 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 captchaApi = {
|
||||
// 获取加密场景 ID,用于前端加密模式初始化滑块
|
||||
getEncryptedSceneId: (params) => request.post('/captcha/encryptedSceneId', params || {}),
|
||||
// 获取验证码配置(是否启用、场景 ID)
|
||||
getConfig: () => request.get('/captcha/config')
|
||||
}
|
||||
|
||||
// 产品相关接口
|
||||
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(',') }
|
||||
}),
|
||||
|
||||
// 下载接口文档(支持PDF和Markdown)
|
||||
downloadProductDocumentation: (productId) => request.get(`/products/${productId}/documentation/download`, {
|
||||
responseType: 'blob'
|
||||
}),
|
||||
|
||||
// 组件报告下载相关API
|
||||
// 检查产品是否可以下载示例报告
|
||||
checkComponentReportAvailability: (productId) => request.get(`/products/${productId}/component-report/check`),
|
||||
// 获取产品示例报告下载信息和价格计算
|
||||
getComponentReportInfo: (productId) => request.get(`/products/${productId}/component-report/info`),
|
||||
// 创建示例报告购买订单
|
||||
createComponentReportPaymentOrder: (productId, data) => request.post(`/products/${productId}/component-report/create-order`, data),
|
||||
// 检查示例报告购买订单支付状态
|
||||
checkComponentReportPaymentStatus: (orderId) => request.get(`/component-report/check-payment/${orderId}`),
|
||||
// 生成并下载示例报告ZIP文件
|
||||
generateAndDownloadComponentReport: (data) => request.post('/component-report/generate-and-download', data, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
// 分类相关接口 - 数据大厅
|
||||
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`),
|
||||
|
||||
// 取消我的订阅 (需认证)
|
||||
cancelMySubscription: (id) => request.post(`/my/subscriptions/${id}/cancel`)
|
||||
}
|
||||
|
||||
// 财务相关接口
|
||||
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),
|
||||
createWechatRecharge: (data) => request.post('/finance/wallet/wechat-recharge', data),
|
||||
getWechatOrderStatus: (params) => request.get('/finance/wallet/wechat-order-status', { params }),
|
||||
|
||||
// 用户密钥相关
|
||||
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 }),
|
||||
|
||||
// 购买记录相关接口
|
||||
getUserPurchaseRecords: (params) => request.get('/finance/purchase-records', { params }),
|
||||
getAdminPurchaseRecords: (params) => request.get('/admin/finance/purchase-records', { params }),
|
||||
exportAdminPurchaseRecords: (params) => request.get('/admin/finance/purchase-records/export', {
|
||||
params,
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
// 认证相关接口
|
||||
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'
|
||||
}
|
||||
}),
|
||||
|
||||
// 上传认证图片到七牛云(企业信息中的营业执照、办公场地、场景附件、授权代表身份证等)
|
||||
uploadFile: (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post('/certifications/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 管理员代用户完成认证(暂不关联合同)
|
||||
adminCompleteWithoutContract: (data) => request.post('/certifications/admin/complete-without-contract', data),
|
||||
|
||||
// 管理端企业审核:列表(按状态机 certification_status 筛选)、详情、通过、拒绝、按用户变更状态
|
||||
adminListSubmitRecords: (params) => request.get('/certifications/admin/submit-records', { params }),
|
||||
adminGetSubmitRecord: (id) => request.get(`/certifications/admin/submit-records/${id}`),
|
||||
adminApproveSubmitRecord: (id, data) => request.post(`/certifications/admin/submit-records/${id}/approve`, data || {}),
|
||||
adminRejectSubmitRecord: (id, data) => request.post(`/certifications/admin/submit-records/${id}/reject`, data),
|
||||
// 管理端按用户变更认证状态(以状态机为准:info_submitted=通过 / info_rejected=拒绝)
|
||||
adminTransitionCertificationStatus: (data) => request.post('/certifications/admin/transition-status', 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: (remark = '') => {
|
||||
const params = {}
|
||||
if (remark) {
|
||||
params.remark = remark
|
||||
}
|
||||
return request.get('/white-list', { params })
|
||||
},
|
||||
// 添加白名单IP (需认证)
|
||||
addWhiteListIP: (ipAddress, remark = '') => request.post('/white-list', { ip_address: ipAddress, remark: remark }),
|
||||
// 删除白名单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}`),
|
||||
|
||||
// 小类管理
|
||||
getSubCategories: (params) => request.get('/admin/sub-categories', { params }),
|
||||
getSubCategoryDetail: (id) => request.get(`/admin/sub-categories/${id}`),
|
||||
createSubCategory: (data) => request.post('/admin/sub-categories', data),
|
||||
updateSubCategory: (id, data) => request.put(`/admin/sub-categories/${id}`, data),
|
||||
deleteSubCategory: (id) => request.delete(`/admin/sub-categories/${id}`),
|
||||
getSubCategoriesByCategory: (categoryId) => request.get(`/admin/product-categories/${categoryId}/sub-categories`),
|
||||
|
||||
// 订阅管理
|
||||
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
@@ -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'
|
||||
})
|
||||
}
|
||||
}
|
||||
689
src/api/statistics/index.js
Normal file
@@ -0,0 +1,689 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// ================ 管理员安全可视化接口 ================
|
||||
|
||||
/**
|
||||
* 获取可疑IP列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function adminGetSuspiciousIPList(params = {}) {
|
||||
return request({
|
||||
url: '/admin/security/suspicious-ip/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可疑IP地球请求流
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export function adminGetSuspiciousIPGeoStream(params = {}) {
|
||||
return request({
|
||||
url: '/admin/security/suspicious-ip/geo-stream',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
99
src/api/ui-component.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const uiComponentApi = {
|
||||
// 获取UI组件列表
|
||||
getUIComponentList(params) {
|
||||
return request.get('/admin/ui-components', { params })
|
||||
},
|
||||
|
||||
// 获取UI组件详情
|
||||
getUIComponentDetail(id) {
|
||||
return request.get(`/admin/ui-components/${id}`)
|
||||
},
|
||||
|
||||
// 创建UI组件
|
||||
createUIComponent(data) {
|
||||
// 确保发送的数据结构与后端期望的完全匹配
|
||||
const requestData = {
|
||||
component_code: data.component_code || '',
|
||||
component_name: data.component_name || '',
|
||||
description: data.description || '',
|
||||
version: data.version || '',
|
||||
is_active: data.is_active !== undefined ? data.is_active : true,
|
||||
sort_order: data.sort_order !== undefined ? data.sort_order : 0
|
||||
}
|
||||
|
||||
// 添加调试日志
|
||||
console.log('创建UI组件请求数据:', requestData)
|
||||
|
||||
return request.post('/admin/ui-components', requestData)
|
||||
},
|
||||
|
||||
// 更新UI组件
|
||||
updateUIComponent(id, data) {
|
||||
// 确保发送的数据结构与后端期望的完全匹配
|
||||
const requestData = {
|
||||
id: id,
|
||||
component_code: data.component_code || '',
|
||||
component_name: data.component_name || '',
|
||||
description: data.description || '',
|
||||
version: data.version || '',
|
||||
is_active: data.is_active !== undefined ? data.is_active : true,
|
||||
sort_order: data.sort_order !== undefined ? data.sort_order : 0
|
||||
}
|
||||
|
||||
// 添加调试日志
|
||||
console.log('更新UI组件请求数据:', requestData)
|
||||
|
||||
return request.put(`/admin/ui-components/${id}`, requestData)
|
||||
},
|
||||
|
||||
// 删除UI组件
|
||||
deleteUIComponent(id) {
|
||||
return request.delete(`/admin/ui-components/${id}`)
|
||||
},
|
||||
|
||||
// 上传UI组件文件
|
||||
uploadUIComponentFile(id, formData) {
|
||||
return request.post(`/admin/ui-components/${id}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 上传并解压UI组件文件
|
||||
uploadAndExtractUIComponentFile(id, formData) {
|
||||
return request.post(`/admin/ui-components/${id}/upload-extract`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 下载UI组件文件
|
||||
downloadUIComponentFile(id) {
|
||||
return request.get(`/admin/ui-components/${id}/download`, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
},
|
||||
|
||||
// 获取UI组件文件夹内容
|
||||
getUIComponentFolderContent(id) {
|
||||
return request.get(`/admin/ui-components/${id}/folder-content`)
|
||||
},
|
||||
|
||||
// 删除UI组件文件夹
|
||||
deleteUIComponentFolder(id) {
|
||||
return request.delete(`/admin/ui-components/${id}/folder`)
|
||||
},
|
||||
|
||||
// 创建UI组件并上传文件(合并操作)
|
||||
createUIComponentWithFile(formData) {
|
||||
return request.post('/admin/ui-components/create-with-file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
38
src/assets/base.css
Normal 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;
|
||||
}
|
||||
BIN
src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
1
src/assets/logo.svg
Normal 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 |
3
src/assets/main.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import './base.css';
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
120
src/assets/styles/auth.css
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
609
src/assets/styles/list-pages.css
Normal file
@@ -0,0 +1,609 @@
|
||||
/* 列表页通用样式 - 科技感、简约、高级设计 */
|
||||
|
||||
/* ===== 页面容器 ===== */
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ===== 筛选区域 ===== */
|
||||
.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-header .flex.justify-between {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.list-page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.list-page-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-page-actions .el-button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-basis: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.list-page-actions .el-button:last-child:nth-child(odd) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ===== 移动端表格优化 ===== */
|
||||
/* 表格容器允许横向滚动 */
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* 隐藏滚动条但保持滚动功能 */
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
/* 移除固定列效果 - 通过覆盖 Element Plus 的固定列样式 */
|
||||
.list-page-container .el-table .el-table__fixed,
|
||||
.list-page-container .el-table .el-table__fixed-right {
|
||||
position: static !important;
|
||||
box-shadow: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* 固定列的表头和表体都改为静态定位 */
|
||||
.list-page-container .el-table .el-table__fixed-header-wrapper,
|
||||
.list-page-container .el-table .el-table__fixed-body-wrapper,
|
||||
.list-page-container .el-table .el-table__fixed-footer-wrapper {
|
||||
position: static !important;
|
||||
}
|
||||
|
||||
/* 表格单元格在移动端优化 */
|
||||
.list-page-container .el-table th,
|
||||
.list-page-container .el-table td {
|
||||
padding: 12px 8px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 操作按钮组在移动端改为紧凑布局 */
|
||||
.list-page-container .el-table .el-table__cell .flex.gap-2,
|
||||
.list-page-container .el-table .el-table__cell .flex.items-center,
|
||||
.list-page-container .el-table .el-table__cell .flex.space-x-2 {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
/* 操作按钮在移动端缩小 */
|
||||
.list-page-container .el-table .el-button--small {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* 表格列宽度优化 - 允许更灵活的宽度 */
|
||||
.list-page-container .el-table .el-table__cell {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* 操作列宽度自适应,不设置最小宽度 */
|
||||
.list-page-container .el-table .el-table__cell[data-label="操作"],
|
||||
.list-page-container .el-table th:last-child,
|
||||
.list-page-container .el-table td:last-child {
|
||||
min-width: auto !important;
|
||||
width: auto !important;
|
||||
}
|
||||
|
||||
/* 隐藏部分次要列在移动端 - 通过类名控制 */
|
||||
.list-page-container .el-table .el-table__cell.hidden-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 操作按钮在移动端自动换行,避免溢出 */
|
||||
.list-page-container .el-table .el-table__cell .el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* 表格在移动端允许横向滚动 */
|
||||
.list-page-container .el-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
/* 操作列在移动端不设置最小宽度,允许换行 */
|
||||
.list-page-container .el-table .el-table__cell[data-label="操作"] {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 操作按钮组在移动端更紧凑 */
|
||||
.list-page-container .el-table .el-table__cell .flex {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* 下拉菜单按钮在移动端优化 */
|
||||
.list-page-container .el-table .el-dropdown .el-button {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超小屏幕进一步优化 */
|
||||
@media (max-width: 480px) {
|
||||
.list-page-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.list-page-header {
|
||||
padding: 16px 12px 12px;
|
||||
}
|
||||
|
||||
.list-page-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.list-page-actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-page-actions .el-button {
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-page-filters {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.list-page-table {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
/* 表格单元格进一步缩小 */
|
||||
.list-page-container .el-table th,
|
||||
.list-page-container .el-table td {
|
||||
padding: 10px 6px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 操作按钮更紧凑 */
|
||||
.list-page-container .el-table .el-button--small {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 操作按钮组更紧凑 */
|
||||
.list-page-container .el-table .el-table__cell .flex.gap-2,
|
||||
.list-page-container .el-table .el-table__cell .flex.items-center {
|
||||
gap: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@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;
|
||||
}
|
||||
186
src/assets/styles/performance.css
Normal 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 {
|
||||
/* 用户偏好减少动画时的样式 */
|
||||
}
|
||||
44
src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve 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>
|
||||
94
src/components/TheWelcome.vue
Normal 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>
|
||||
|
||||
Vue’s
|
||||
<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>
|
||||
86
src/components/WelcomeItem.vue
Normal 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>
|
||||
501
src/components/admin/ProductApiConfigDialog.vue
Normal 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>
|
||||
671
src/components/admin/ProductDocumentationDialog.vue
Normal 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.haiyudata.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>
|
||||
1169
src/components/admin/ProductFormDialog.vue
Normal file
108
src/components/auth/PermissionGuard.vue
Normal 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>
|
||||
|
||||
206
src/components/common/AppLoading.vue
Normal 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>
|
||||
412
src/components/common/BusinessConsultationDialog.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="currentStep === 'select' ? '' : '商务洽谈'"
|
||||
:width="isMobile ? '96vw' : '500px'"
|
||||
:top="isMobile ? '4vh' : '15vh'"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
class="business-consultation-dialog"
|
||||
:z-index="9999"
|
||||
append-to-body
|
||||
@close="handleDialogClose"
|
||||
>
|
||||
<!-- 选择咨询类型 -->
|
||||
<div v-if="currentStep === 'select'" class="consultation-type-select">
|
||||
<h2 class="consultation-title">选择咨询类型</h2>
|
||||
<div class="consultation-buttons">
|
||||
<el-button
|
||||
class="consultation-button business-button"
|
||||
@click="selectBusinessConsultation"
|
||||
>
|
||||
商务咨询
|
||||
</el-button>
|
||||
<el-button
|
||||
class="consultation-button technical-button"
|
||||
@click="selectTechnicalConsultation"
|
||||
>
|
||||
技术咨询
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 商务洽谈内容 -->
|
||||
<div v-else 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>
|
||||
<el-button
|
||||
class="mt-6"
|
||||
type="primary"
|
||||
@click="closeDialog"
|
||||
>
|
||||
关闭
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, 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)
|
||||
const currentStep = ref('select') // 'select' 或 'business'
|
||||
const isMobile = ref(false)
|
||||
|
||||
const checkIsMobile = () => {
|
||||
isMobile.value = window.innerWidth <= 640
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkIsMobile()
|
||||
window.addEventListener('resize', checkIsMobile)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkIsMobile)
|
||||
})
|
||||
|
||||
// 监听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 selectBusinessConsultation = () => {
|
||||
currentStep.value = 'business'
|
||||
}
|
||||
|
||||
// 选择技术咨询
|
||||
const selectTechnicalConsultation = () => {
|
||||
window.location.href = 'https://work.weixin.qq.com/kfid/kfca4ad06d79a6c1b45'
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
// 处理对话框关闭事件,重置状态
|
||||
const handleDialogClose = () => {
|
||||
currentStep.value = 'select'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.business-consultation-dialog :deep(.el-dialog) {
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.business-consultation-dialog :deep(.el-dialog__header) {
|
||||
padding: 20px 20px 0;
|
||||
}
|
||||
|
||||
.business-consultation-dialog :deep(.el-dialog__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.business-consultation-dialog :deep(.el-dialog__headerbtn) {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.consultation-type-select {
|
||||
padding: 30px 20px 40px;
|
||||
}
|
||||
|
||||
.consultation-title {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
margin: 0 0 30px 0;
|
||||
}
|
||||
|
||||
.consultation-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.consultation-button {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.consultation-button :deep(.el-button__inner) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.business-button {
|
||||
background-color: #409eff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.business-button:hover {
|
||||
background-color: #66b1ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
.technical-button {
|
||||
background-color: #67c23a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.technical-button:hover {
|
||||
background-color: #85ce61;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(103, 194, 58, 0.4);
|
||||
}
|
||||
|
||||
.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;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.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: 640px) {
|
||||
.business-consultation-dialog :deep(.el-dialog) {
|
||||
margin: 0 auto;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.business-consultation-dialog :deep(.el-dialog__header) {
|
||||
padding: 12px 12px 0;
|
||||
}
|
||||
|
||||
.business-consultation-dialog :deep(.el-dialog__body) {
|
||||
padding: 14px 12px 18px;
|
||||
}
|
||||
|
||||
.consultation-type-select {
|
||||
padding: 12px 6px 18px;
|
||||
}
|
||||
|
||||
.consultation-title {
|
||||
font-size: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.consultation-buttons {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.consultation-button {
|
||||
max-width: 100%;
|
||||
height: 44px;
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.consultation-content {
|
||||
padding: 10px 4px 16px;
|
||||
}
|
||||
|
||||
.consultation-info h4 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.consultation-info p {
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.benefits-list {
|
||||
margin: 10px 0 16px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.benefits-list li {
|
||||
font-size: 13px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.qr-code-image,
|
||||
.qr-code-placeholder {
|
||||
width: 70vw;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.qr-code-image {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.qr-code-tip {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.consultation-buttons :deep(.el-button__text) {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
127
src/components/common/CertificationBanner.vue
Normal 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>
|
||||
102
src/components/common/CertificationNotice.vue
Normal 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>
|
||||
399
src/components/common/CodeDisplay.vue
Normal 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>
|
||||
342
src/components/common/CustomSteps.vue
Normal 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>
|
||||
379
src/components/common/DanmakuBar.vue
Normal file
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3 flex flex-col" style="min-height: 100px; height: 100px;">
|
||||
<div class="flex items-center justify-between mb-2 pb-2 border-b border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<el-icon size="16" class="mr-2">
|
||||
<TrendCharts />
|
||||
</el-icon>
|
||||
<h3 class="text-sm font-semibold text-gray-800">实时动态</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="danmakuWrapper" class="relative flex-1 overflow-hidden" style="height: 100px;">
|
||||
<div
|
||||
v-for="danmaku in activeDanmakus"
|
||||
:key="danmaku.id"
|
||||
:data-danmaku-id="danmaku.id"
|
||||
:class="['danmaku-item', `danmaku-${danmaku.status}`]"
|
||||
:style="{
|
||||
animationDuration: `${danmaku.duration}ms`
|
||||
}"
|
||||
@animationend="handleAnimationEnd(danmaku.id)"
|
||||
>
|
||||
<div :class="['danmaku-content', `danmaku-content-${danmaku.status}`]">
|
||||
<span :class="['company-name', `company-name-${danmaku.status}`]">{{ danmaku.companyName || '未知企业' }}</span>
|
||||
<span class="separator">·</span>
|
||||
<span :class="['product-name', `product-name-${danmaku.status}`]">{{ danmaku.productName || '未知产品' }}</span>
|
||||
<span class="separator">·</span>
|
||||
<span class="time-ago">{{ danmaku.timeAgo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { apiCallApi } from '@/api'
|
||||
import { TrendCharts } from '@element-plus/icons-vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 3000 // 默认3秒刷新一次
|
||||
},
|
||||
danmakuSpeed: {
|
||||
type: Number,
|
||||
default: 15000 // 弹幕滚动速度(毫秒),25秒让弹幕显示更久
|
||||
}
|
||||
})
|
||||
|
||||
// 固定配置
|
||||
const FETCH_PAGE_SIZE = 8// 每次获取的记录数
|
||||
const BASE_EMIT_INTERVAL = 5500// 基础5秒,避免弹幕重叠
|
||||
const RANDOM_EMIT_RANGE = 2000 // 0-1秒随机范围
|
||||
|
||||
const enabled = ref(true)
|
||||
const danmakuWrapper = ref(null)
|
||||
const activeDanmakus = ref([]) // 当前正在显示的弹幕
|
||||
const pendingQueue = ref([]) // 待处理的弹幕队列
|
||||
const lastFetchTime = ref(null)
|
||||
const fetchTimer = ref(null)
|
||||
const processedIds = ref(new Set()) // 已处理的记录ID,确保每条数据只显示一次
|
||||
const emitTimer = ref(null) // 弹幕发射定时器
|
||||
const timeUpdateTimer = ref(null) // 时间更新定时器
|
||||
|
||||
// 计算时间差(多少分钟前)
|
||||
const calculateTimeAgo = (timeStr) => {
|
||||
if (!timeStr) return '刚刚'
|
||||
|
||||
try {
|
||||
const time = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - time) / 1000 / 60) // 分钟差
|
||||
|
||||
if (diff < 1) return '刚刚'
|
||||
if (diff < 60) return `${diff}分钟前`
|
||||
|
||||
const hours = Math.floor(diff / 60)
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}天前`
|
||||
} catch (e) {
|
||||
return '刚刚'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最新API调用记录
|
||||
const fetchLatestApiCalls = async () => {
|
||||
if (!enabled.value) return
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: 1,
|
||||
page_size: FETCH_PAGE_SIZE,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
}
|
||||
|
||||
// 如果上次获取过数据,只获取更新的记录
|
||||
if (lastFetchTime.value) {
|
||||
const now = new Date()
|
||||
const oneMinuteAgo = new Date(now.getTime() - 60000) // 1分钟前
|
||||
params.start_time = oneMinuteAgo.toISOString().replace('T', ' ').substring(0, 19)
|
||||
}
|
||||
|
||||
const response = await apiCallApi.getAdminApiCalls(params)
|
||||
|
||||
// 处理响应数据,兼容不同的响应格式
|
||||
let items = []
|
||||
if (response && response.data) {
|
||||
if (response.data.items) {
|
||||
items = response.data.items
|
||||
} else if (Array.isArray(response.data)) {
|
||||
items = response.data
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
// 过滤已处理过的记录,确保每条数据只显示一次
|
||||
const newItems = items.filter(item => {
|
||||
if (processedIds.value.has(item.id)) {
|
||||
return false
|
||||
}
|
||||
processedIds.value.add(item.id)
|
||||
return true
|
||||
})
|
||||
|
||||
// 按时间顺序排序(从旧到新),确保按顺序显示
|
||||
newItems.sort((a, b) => {
|
||||
const timeA = new Date(a.created_at || a.start_at || 0)
|
||||
const timeB = new Date(b.created_at || b.start_at || 0)
|
||||
return timeA - timeB
|
||||
})
|
||||
|
||||
// 将新记录按顺序添加到待处理队列
|
||||
newItems.forEach(item => {
|
||||
addToPendingQueue(item)
|
||||
})
|
||||
|
||||
lastFetchTime.value = new Date()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取API调用记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到待处理队列
|
||||
const addToPendingQueue = (item) => {
|
||||
pendingQueue.value.push(item)
|
||||
|
||||
// 限制队列大小,避免内存溢出(最多保留150条)
|
||||
if (pendingQueue.value.length > 150) {
|
||||
pendingQueue.value.shift() // 移除最旧的一条
|
||||
}
|
||||
}
|
||||
|
||||
// 发射弹幕(按固定间隔连续发射,允许多条同时显示)
|
||||
const emitDanmaku = () => {
|
||||
if (!enabled.value) {
|
||||
emitTimer.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 从队列取出一条弹幕
|
||||
if (pendingQueue.value.length === 0) {
|
||||
// 队列为空,等待一段时间后重试
|
||||
emitTimer.value = setTimeout(() => {
|
||||
emitDanmaku()
|
||||
}, 1000)
|
||||
return
|
||||
}
|
||||
|
||||
const item = pendingQueue.value.shift()
|
||||
if (!item) {
|
||||
emitTimer.value = setTimeout(() => {
|
||||
emitDanmaku()
|
||||
}, 1000)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建弹幕对象(使用唯一ID确保不重复)
|
||||
const danmaku = {
|
||||
id: `danmaku-${item.id}-${Date.now()}-${Math.random()}`,
|
||||
companyName: item.company_name || item.user?.company_name || '未知企业',
|
||||
productName: item.product_name || '未知产品',
|
||||
status: item.status || 'pending',
|
||||
startAt: item.start_at || item.created_at,
|
||||
timeAgo: calculateTimeAgo(item.start_at || item.created_at),
|
||||
duration: props.danmakuSpeed
|
||||
}
|
||||
|
||||
// 添加到活跃弹幕列表(立即开始动画)
|
||||
activeDanmakus.value.push(danmaku)
|
||||
|
||||
// 安排下一条弹幕的发射(随机间隔:5s + 0-1s)
|
||||
const interval = BASE_EMIT_INTERVAL + Math.random() * RANDOM_EMIT_RANGE
|
||||
emitTimer.value = setTimeout(() => {
|
||||
emitDanmaku()
|
||||
}, interval)
|
||||
}
|
||||
|
||||
// 启动弹幕发射系统
|
||||
const startEmissionSystem = () => {
|
||||
if (emitTimer.value) {
|
||||
clearTimeout(emitTimer.value)
|
||||
}
|
||||
emitDanmaku() // 开始第一次发射
|
||||
}
|
||||
|
||||
// 处理动画结束事件(弹幕移出屏幕时移除)
|
||||
const handleAnimationEnd = (id) => {
|
||||
const index = activeDanmakus.value.findIndex(d => d.id === id)
|
||||
if (index > -1) {
|
||||
activeDanmakus.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新所有弹幕的时间显示
|
||||
const updateTimeAgo = () => {
|
||||
activeDanmakus.value.forEach(danmaku => {
|
||||
if (danmaku.startAt) {
|
||||
danmaku.timeAgo = calculateTimeAgo(danmaku.startAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 开始获取数据
|
||||
const startFetching = () => {
|
||||
// 立即获取一次
|
||||
fetchLatestApiCalls()
|
||||
|
||||
// 设置定时器定期获取数据
|
||||
if (fetchTimer.value) {
|
||||
clearInterval(fetchTimer.value)
|
||||
}
|
||||
fetchTimer.value = setInterval(() => {
|
||||
fetchLatestApiCalls()
|
||||
}, props.refreshInterval)
|
||||
|
||||
// 启动弹幕发射系统
|
||||
startEmissionSystem()
|
||||
|
||||
// 定时更新"多少分钟前"
|
||||
if (timeUpdateTimer.value) {
|
||||
clearInterval(timeUpdateTimer.value)
|
||||
}
|
||||
timeUpdateTimer.value = setInterval(() => {
|
||||
if (enabled.value) {
|
||||
updateTimeAgo()
|
||||
}
|
||||
}, 60000) // 每分钟更新一次时间显示
|
||||
}
|
||||
|
||||
// 停止获取数据
|
||||
const stopFetching = () => {
|
||||
if (fetchTimer.value) {
|
||||
clearInterval(fetchTimer.value)
|
||||
fetchTimer.value = null
|
||||
}
|
||||
if (emitTimer.value) {
|
||||
clearTimeout(emitTimer.value)
|
||||
emitTimer.value = null
|
||||
}
|
||||
if (timeUpdateTimer.value) {
|
||||
clearInterval(timeUpdateTimer.value)
|
||||
timeUpdateTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (enabled.value) {
|
||||
startFetching()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopFetching()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.danmaku-item {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
white-space: nowrap;
|
||||
animation: danmaku-scroll linear forwards;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
@keyframes danmaku-scroll {
|
||||
from {
|
||||
transform: translate(100%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translate(-200%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.danmaku-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: linear-gradient(to right, #eff6ff, #faf5ff);
|
||||
border: 1px solid #bfdbfe;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.danmaku-content-success {
|
||||
background: linear-gradient(to right, #f0fdf4, #ecfdf5);
|
||||
border-color: #86efac;
|
||||
}
|
||||
|
||||
.danmaku-content-failed {
|
||||
background: linear-gradient(to right, #fef2f2, #fff1f2);
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
.danmaku-content-pending {
|
||||
background: linear-gradient(to right, #fefce8, #fffbeb);
|
||||
border-color: #fde047;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.company-name-success {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.company-name-failed {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.company-name-pending {
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.product-name-success {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.product-name-failed {
|
||||
color: #e11d48;
|
||||
}
|
||||
|
||||
.product-name-pending {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.product-id {
|
||||
color: #4b5563;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.time-ago {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
205
src/components/common/ExportDialog.md
Normal 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格式导出
|
||||
- ✅ 异步搜索和批量加载
|
||||
- ✅ 完整的错误处理和用户反馈
|
||||
321
src/components/common/ExportDialog.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
:width="isMobile ? '90%' : '600px'"
|
||||
:close-on-click-modal="false"
|
||||
class="export-dialog"
|
||||
>
|
||||
<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', isMobile ? 'flex-col' : '']">
|
||||
<el-button @click="handleCancel" :class="isMobile ? 'w-full' : ''">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleConfirm"
|
||||
:class="isMobile ? 'w-full' : ''"
|
||||
>
|
||||
确认导出
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { productApi, userApi } from '@/api'
|
||||
import { useMobileTable } from '@/composables/useMobileTable'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
|
||||
// 移动端检测
|
||||
const { isMobile } = useMobileTable()
|
||||
|
||||
// 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>
|
||||
|
||||
<style>
|
||||
/* 导出弹窗移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.export-dialog :deep(.el-dialog__body) {
|
||||
padding: 16px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
138
src/components/common/FileUpload.md
Normal 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. 组件支持响应式设计,在移动端会自动调整布局
|
||||
371
src/components/common/FileUpload.vue
Normal 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>
|
||||
19
src/components/common/FilterItem.vue
Normal 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>
|
||||
24
src/components/common/FilterSection.vue
Normal 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>
|
||||
119
src/components/common/FloatingCustomerService.vue
Normal 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>
|
||||
75
src/components/common/ListPageLayout.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<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>
|
||||
import { useMobileTable } from '@/composables/useMobileTable'
|
||||
import { watch, nextTick, onMounted } from 'vue'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
// 移动端表格优化
|
||||
const { isMobile, removeFixedColumns } = useMobileTable()
|
||||
|
||||
// 监听表格内容变化,重新应用优化
|
||||
watch(() => isMobile.value, () => {
|
||||
nextTick(() => {
|
||||
removeFixedColumns()
|
||||
})
|
||||
})
|
||||
|
||||
// 在组件挂载后应用优化
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
removeFixedColumns()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
338
src/components/common/MarkdownEditor.vue
Normal 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>
|
||||
314
src/components/common/README.md
Normal 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` 的使用
|
||||
|
||||
认证提示现在会自动在主布局中显示,无需手动管理。
|
||||
143
src/components/common/ResponsiveActionColumn.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="responsive-action-column">
|
||||
<!-- 桌面端:显示所有按钮 -->
|
||||
<div v-if="!isMobile" class="action-buttons-desktop">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 移动端:主要操作按钮 + 更多操作下拉菜单 -->
|
||||
<div v-else class="action-buttons-mobile">
|
||||
<!-- 主要操作按钮(最多显示2个) -->
|
||||
<template v-for="(action, index) in primaryActions" :key="index">
|
||||
<el-button
|
||||
:type="action.type || 'default'"
|
||||
:size="action.size || 'small'"
|
||||
@click="action.handler"
|
||||
>
|
||||
{{ action.label }}
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 更多操作下拉菜单 -->
|
||||
<el-dropdown v-if="moreActions.length > 0" @command="handleCommand" trigger="click">
|
||||
<el-button type="info" size="small">
|
||||
更多
|
||||
<el-icon class="el-icon--right">
|
||||
<ArrowDown />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="(action, index) in moreActions"
|
||||
:key="index"
|
||||
:command="action"
|
||||
>
|
||||
<el-icon v-if="action.icon" class="dropdown-item-icon">
|
||||
<component :is="action.icon" />
|
||||
</el-icon>
|
||||
{{ action.label }}
|
||||
</el-dropdown-item>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { useMobileTable } from '@/composables/useMobileTable'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
// 主要操作按钮数量(移动端显示)
|
||||
primaryCount: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
// 操作按钮配置(如果使用配置方式)
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const { isMobile } = useMobileTable()
|
||||
const slots = useSlots()
|
||||
|
||||
// 从插槽中提取按钮信息(如果使用插槽方式)
|
||||
const extractActionsFromSlots = () => {
|
||||
if (!slots.default) return []
|
||||
|
||||
const actions = []
|
||||
// 这里需要从插槽中解析按钮,但 Vue 3 的插槽是渲染函数,比较难解析
|
||||
// 所以建议使用 actions prop 方式
|
||||
return actions
|
||||
}
|
||||
|
||||
// 计算主要操作和更多操作
|
||||
const primaryActions = computed(() => {
|
||||
if (props.actions.length > 0) {
|
||||
return props.actions.slice(0, props.primaryCount)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const moreActions = computed(() => {
|
||||
if (props.actions.length > 0) {
|
||||
return props.actions.slice(props.primaryCount)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 处理下拉菜单命令
|
||||
const handleCommand = (action) => {
|
||||
if (action && action.handler) {
|
||||
action.handler()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.responsive-action-column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-buttons-desktop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-buttons-mobile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dropdown-item-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 移动端按钮更紧凑 */
|
||||
@media (max-width: 768px) {
|
||||
.action-buttons-mobile .el-button {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.action-buttons-mobile .el-button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
86
src/components/common/RichTextEditor.vue
Normal 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>
|
||||
86
src/components/common/VersionInfo.vue
Normal 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>
|
||||
91
src/components/common/withCertificationCheck.js
Normal 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
|
||||
})
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
126
src/components/layout/AppBreadcrumb.vue
Normal 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>
|
||||
333
src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,333 @@
|
||||
<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 command="home">
|
||||
<el-icon class="dropdown-item-icon">
|
||||
<Home />
|
||||
</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,
|
||||
HomeIcon as Home,
|
||||
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>
|
||||
427
src/components/layout/AppSidebar.vue
Normal file
@@ -0,0 +1,427 @@
|
||||
<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 class="home-link-container">
|
||||
<a
|
||||
href="https://www.haiyudata.com/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="home-link"
|
||||
@click="handleMenuSelect"
|
||||
>
|
||||
<el-icon class="menu-icon">
|
||||
<HomeIcon />
|
||||
</el-icon>
|
||||
<span class="menu-title">返回官网</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</el-aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { HomeIcon } from '@heroicons/vue/24/outline'
|
||||
import { onMounted, onUnmounted, watch } from 'vue'
|
||||
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()
|
||||
let bodyLockClassApplied = false
|
||||
|
||||
// 使用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()
|
||||
}
|
||||
}
|
||||
|
||||
// 当移动端侧边栏打开时,锁定页面点击与滚动,确保其他区域点击无效
|
||||
const toggleBodyLock = (locked) => {
|
||||
const body = document.body
|
||||
if (locked) {
|
||||
if (!bodyLockClassApplied) {
|
||||
body.classList.add('sidebar-locked')
|
||||
bodyLockClassApplied = true
|
||||
}
|
||||
} else if (bodyLockClassApplied) {
|
||||
body.classList.remove('sidebar-locked')
|
||||
bodyLockClassApplied = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
toggleBodyLock(appStore.isMobile && appStore.mobileSidebarOpen)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
toggleBodyLock(false)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [appStore.isMobile, appStore.mobileSidebarOpen],
|
||||
([isMobile, open]) => {
|
||||
toggleBodyLock(isMobile && open)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</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;
|
||||
width: 100%;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border: none;
|
||||
height: 100%;
|
||||
flex: 1 1 0%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 返回官网链接样式 */
|
||||
.home-link-container {
|
||||
padding: 8px;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.6);
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
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;
|
||||
text-decoration: none;
|
||||
color: #1e293b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.home-link:hover {
|
||||
background-color: #f5f7fa;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.home-link .menu-icon {
|
||||
font-size: 18px;
|
||||
margin-right: 12px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.home-link .menu-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 折叠状态下的返回官网链接 */
|
||||
.el-menu--collapse ~ .home-link-container .home-link {
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
margin: 4px 4px;
|
||||
}
|
||||
|
||||
.el-menu--collapse ~ .home-link-container .home-link .menu-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.el-menu--collapse ~ .home-link-container .home-link .menu-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 移动端样式 */
|
||||
.mobile-sidebar {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
height: calc(100vh - 60px);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.mobile-sidebar.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.65); /* 保持浅色,不要黑色遮罩 */
|
||||
backdrop-filter: blur(2px);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-menu {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.sidebar-menu {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin: 2px 4px;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.home-link {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin: 2px 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.home-link .menu-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.home-link .menu-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.sidebar-locked) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(body.sidebar-locked #app) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(body.sidebar-locked .app-sidebar) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 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>
|
||||
308
src/components/layout/NotificationPanel.vue
Normal 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>
|
||||
453
src/components/product/ProductCard.vue
Normal file
@@ -0,0 +1,453 @@
|
||||
<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="danger"
|
||||
@click="handleCancelSubscribe"
|
||||
class="action-btn cancel-subscribe-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
|
||||
},
|
||||
subscription: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['view-detail', 'subscribe', 'cancel-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)
|
||||
}
|
||||
|
||||
// 取消订阅
|
||||
const handleCancelSubscribe = () => {
|
||||
emit('cancel-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);
|
||||
}
|
||||
|
||||
.cancel-subscribe-btn {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
box-shadow: 0 2px 4px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.cancel-subscribe-btn:hover {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||||
border-color: #dc2626;
|
||||
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.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>
|
||||
543
src/components/statistics/ChartCard.vue
Normal 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>
|
||||
|
||||
408
src/components/statistics/StatCard.vue
Normal 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>
|
||||
|
||||
382
src/components/statistics/StatisticsDashboard.vue
Normal 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>
|
||||
220
src/composables/useAliyunCaptcha.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { captchaApi } from '@/api'
|
||||
|
||||
// 阿里云验证码场景 ID(需与后端 config.sms.scene_id 一致;加密模式可由后端 config 下发)
|
||||
const ALIYUN_CAPTCHA_SCENE_ID = import.meta.env.VITE_CAPTCHA_SCENE_ID || "wynt39to"
|
||||
// 是否启用加密模式:通过环境变量 VITE_CAPTCHA_ENCRYPTED_MODE 控制,为 'true' 不加密
|
||||
const ENABLE_ENCRYPTED = import.meta.env.VITE_CAPTCHA_ENCRYPTED_MODE === 'true'
|
||||
|
||||
let captchaEnabled = true
|
||||
let captchaSceneId = ALIYUN_CAPTCHA_SCENE_ID
|
||||
let captchaConfigLoaded = false
|
||||
let captchaConfigPromise = null
|
||||
|
||||
let captchaInitialised = false
|
||||
let captchaReadyPromise = null
|
||||
let captchaReadyResolve = null
|
||||
|
||||
async function loadCaptchaConfig() {
|
||||
if (captchaConfigLoaded) return
|
||||
if (captchaConfigPromise) {
|
||||
await captchaConfigPromise
|
||||
return
|
||||
}
|
||||
|
||||
captchaConfigPromise = (async () => {
|
||||
try {
|
||||
const resp = await captchaApi.getConfig()
|
||||
const configData = resp?.data?.data ?? resp?.data ?? {}
|
||||
captchaEnabled = configData?.captchaEnabled !== false
|
||||
captchaSceneId = configData?.sceneId || ALIYUN_CAPTCHA_SCENE_ID
|
||||
captchaConfigLoaded = true
|
||||
} catch {
|
||||
// 配置获取失败时默认启用,避免安全能力被意外绕过
|
||||
captchaEnabled = true
|
||||
captchaSceneId = ALIYUN_CAPTCHA_SCENE_ID
|
||||
captchaConfigLoaded = true
|
||||
} finally {
|
||||
captchaConfigPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
await captchaConfigPromise
|
||||
}
|
||||
|
||||
async function ensureCaptchaInit() {
|
||||
await loadCaptchaConfig()
|
||||
if (!captchaEnabled) return
|
||||
|
||||
if (captchaInitialised || typeof window === "undefined") return
|
||||
if (typeof window.initAliyunCaptcha !== "function") return
|
||||
|
||||
captchaInitialised = true
|
||||
window.captcha = null
|
||||
window.__lastBizResponse = null
|
||||
window.__onCaptchaBizSuccess = null
|
||||
captchaReadyPromise = new Promise((resolve) => {
|
||||
captchaReadyResolve = resolve
|
||||
})
|
||||
|
||||
// 非加密模式:仅传 SceneId,不调用后端接口
|
||||
if (!ENABLE_ENCRYPTED) {
|
||||
window.initAliyunCaptcha({
|
||||
SceneId: captchaSceneId,
|
||||
mode: "popup",
|
||||
element: "#captcha-element",
|
||||
getInstance(instance) {
|
||||
window.captcha = instance
|
||||
if (typeof captchaReadyResolve === "function") {
|
||||
captchaReadyResolve()
|
||||
captchaReadyResolve = null
|
||||
}
|
||||
},
|
||||
captchaVerifyCallback(param) {
|
||||
console.log("captchaVerifyCallback", param)
|
||||
return typeof window.__captchaVerifyCallback === "function"
|
||||
? window.__captchaVerifyCallback(param)
|
||||
: Promise.resolve({
|
||||
captchaResult: false,
|
||||
bizResult: false,
|
||||
})
|
||||
},
|
||||
onBizResultCallback(bizResult) {
|
||||
if (typeof window.__onBizResultCallback === "function") {
|
||||
window.__onBizResultCallback(bizResult)
|
||||
}
|
||||
window.__lastBizResponse = null
|
||||
window.__onCaptchaBizSuccess = null
|
||||
},
|
||||
slideStyle: { width: 360, height: 40 },
|
||||
language: "cn",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 加密模式:先从后端获取 EncryptedSceneId,再初始化
|
||||
try {
|
||||
const resp = await captchaApi.getEncryptedSceneId()
|
||||
const encryptedSceneId = resp?.data?.data?.encryptedSceneId ?? resp?.data?.encryptedSceneId
|
||||
if (!encryptedSceneId) {
|
||||
ElMessage.error("获取验证码参数失败,请稍后重试")
|
||||
captchaInitialised = false
|
||||
captchaReadyPromise = null
|
||||
captchaReadyResolve = null
|
||||
return
|
||||
}
|
||||
window.initAliyunCaptcha({
|
||||
SceneId: captchaSceneId,
|
||||
EncryptedSceneId: encryptedSceneId,
|
||||
mode: "popup",
|
||||
element: "#captcha-element",
|
||||
getInstance(instance) {
|
||||
window.captcha = instance
|
||||
if (typeof captchaReadyResolve === "function") {
|
||||
captchaReadyResolve()
|
||||
captchaReadyResolve = null
|
||||
}
|
||||
},
|
||||
captchaVerifyCallback(param) {
|
||||
return typeof window.__captchaVerifyCallback === "function"
|
||||
? window.__captchaVerifyCallback(param)
|
||||
: Promise.resolve({ captchaResult: false, bizResult: false })
|
||||
},
|
||||
onBizResultCallback(bizResult) {
|
||||
if (typeof window.__onBizResultCallback === "function") {
|
||||
window.__onBizResultCallback(bizResult)
|
||||
}
|
||||
window.__lastBizResponse = null
|
||||
window.__onCaptchaBizSuccess = null
|
||||
},
|
||||
slideStyle: { width: 360, height: 40 },
|
||||
language: "cn",
|
||||
})
|
||||
} catch {
|
||||
ElMessage.error("获取验证码参数失败,请稍后重试")
|
||||
captchaInitialised = false
|
||||
captchaReadyPromise = null
|
||||
captchaReadyResolve = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 阿里云滑块验证码通用封装。
|
||||
* 依赖 index.html 中已加载的 AliyunCaptcha.js;初始化在首次调起时执行。
|
||||
*/
|
||||
export function useAliyunCaptcha() {
|
||||
/**
|
||||
* 先弹出滑块,通过后执行 bizVerify(captchaVerifyParam),再根据结果调用 onSuccess。
|
||||
* @param { (captchaVerifyParam: string) => Promise<{ success: boolean, data: any, error?: any }> } bizVerify - 业务请求函数,接收滑块参数
|
||||
* @param { (res: any) => void } onSuccess - 业务成功回调
|
||||
*/
|
||||
async function runWithCaptcha(bizVerify, onSuccess) {
|
||||
if (typeof window === "undefined") {
|
||||
ElMessage.error("验证码仅支持浏览器环境")
|
||||
return
|
||||
}
|
||||
|
||||
await loadCaptchaConfig()
|
||||
|
||||
// 后端统一开关关闭时,前端直接跳过滑块校验并继续业务流程
|
||||
if (!captchaEnabled) {
|
||||
const result = await bizVerify(null)
|
||||
if (typeof onSuccess === "function") {
|
||||
onSuccess(result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const loadingInstance = ElMessage({
|
||||
message: "安全验证加载中...",
|
||||
type: "info",
|
||||
duration: 0,
|
||||
iconClass: "el-icon-loading"
|
||||
})
|
||||
|
||||
try {
|
||||
window.__captchaVerifyCallback = async (captchaVerifyParam) => {
|
||||
window.__lastBizResponse = null
|
||||
try {
|
||||
const result = await bizVerify(captchaVerifyParam)
|
||||
window.__lastBizResponse = result
|
||||
const captchaOk = result?.data?.captchaVerifyResult !== false
|
||||
const bizOk = result.success === true
|
||||
return { captchaResult: captchaOk, bizResult: bizOk }
|
||||
} catch {
|
||||
return { captchaResult: false, bizResult: false }
|
||||
}
|
||||
}
|
||||
|
||||
window.__onBizResultCallback = (bizResult) => {
|
||||
if (
|
||||
bizResult === true &&
|
||||
window.__lastBizResponse &&
|
||||
typeof window.__onCaptchaBizSuccess === "function"
|
||||
) {
|
||||
window.__onCaptchaBizSuccess(window.__lastBizResponse)
|
||||
}
|
||||
}
|
||||
|
||||
await ensureCaptchaInit()
|
||||
|
||||
// 首次初始化时 SDK 会异步调用 getInstance,需等待实例就绪后再 show
|
||||
if (captchaReadyPromise) {
|
||||
await captchaReadyPromise
|
||||
captchaReadyPromise = null
|
||||
}
|
||||
if (!window.captcha) {
|
||||
ElMessage.error("验证码未加载,请刷新页面重试")
|
||||
return
|
||||
}
|
||||
window.__onCaptchaBizSuccess = onSuccess
|
||||
window.captcha.show()
|
||||
} finally {
|
||||
loadingInstance.close()
|
||||
}
|
||||
}
|
||||
|
||||
return { runWithCaptcha }
|
||||
}
|
||||
|
||||
export default useAliyunCaptcha
|
||||
77
src/composables/useCertification.js
Normal 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
|
||||
}
|
||||
}
|
||||
101
src/composables/useMobileTable.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
/**
|
||||
* 移动端表格优化 composable
|
||||
* 用于在移动端移除表格固定列,优化显示效果
|
||||
*/
|
||||
export function useMobileTable() {
|
||||
const isMobile = ref(false)
|
||||
const isTablet = ref(false)
|
||||
|
||||
// 检测屏幕尺寸
|
||||
const checkScreenSize = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
const width = window.innerWidth
|
||||
isMobile.value = width < 768
|
||||
isTablet.value = width >= 768 && width < 1024
|
||||
}
|
||||
|
||||
// 移除表格固定列
|
||||
const removeFixedColumns = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// 只在移动端执行
|
||||
if (!isMobile.value) {
|
||||
// 桌面端恢复固定列样式
|
||||
const tables = document.querySelectorAll('.list-page-container .el-table')
|
||||
tables.forEach((table) => {
|
||||
const fixedElements = table.querySelectorAll('.el-table__fixed, .el-table__fixed-right')
|
||||
fixedElements.forEach((el) => {
|
||||
el.style.position = ''
|
||||
el.style.boxShadow = ''
|
||||
el.style.backgroundColor = ''
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 使用 nextTick 确保 DOM 已更新
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
const tables = document.querySelectorAll('.list-page-container .el-table')
|
||||
tables.forEach((table) => {
|
||||
// 移除固定列元素
|
||||
const fixedElements = table.querySelectorAll('.el-table__fixed, .el-table__fixed-right')
|
||||
fixedElements.forEach((el) => {
|
||||
el.style.position = 'static'
|
||||
el.style.boxShadow = 'none'
|
||||
el.style.backgroundColor = 'transparent'
|
||||
el.style.zIndex = 'auto'
|
||||
})
|
||||
|
||||
// 移除固定列的表头、表体、表尾包装器
|
||||
const fixedWrappers = table.querySelectorAll(
|
||||
'.el-table__fixed-header-wrapper, .el-table__fixed-body-wrapper, .el-table__fixed-footer-wrapper'
|
||||
)
|
||||
fixedWrappers.forEach((el) => {
|
||||
el.style.position = 'static'
|
||||
})
|
||||
|
||||
// 移除固定列的遮罩层
|
||||
const fixedPatch = table.querySelectorAll('.el-table__fixed-right-patch, .el-table__fixed-patch')
|
||||
fixedPatch.forEach((el) => {
|
||||
el.style.display = 'none'
|
||||
})
|
||||
})
|
||||
}, 150)
|
||||
})
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
const handleResize = () => {
|
||||
const wasMobile = isMobile.value
|
||||
checkScreenSize()
|
||||
// 如果移动状态发生变化,重新应用优化
|
||||
if (wasMobile !== isMobile.value) {
|
||||
removeFixedColumns()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkScreenSize()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', handleResize)
|
||||
// 初始移除固定列
|
||||
removeFixedColumns()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isTablet,
|
||||
removeFixedColumns
|
||||
}
|
||||
}
|
||||
|
||||
127
src/constants/documentationDefaults.js
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
195
src/constants/menu.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
ChartBarIcon as ChartBar,
|
||||
ChartPieIcon as ChartPie,
|
||||
ClipboardDocumentListIcon as Clipboard,
|
||||
CreditCardIcon as CreditCard,
|
||||
CubeIcon as Cube,
|
||||
DocumentTextIcon as DocumentText,
|
||||
MegaphoneIcon as Megaphone,
|
||||
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/purchase-records', icon: ShoppingCart, 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/announcements',
|
||||
icon: Megaphone
|
||||
},
|
||||
{
|
||||
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/certification-reviews', icon: ShieldCheck },
|
||||
{ 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/announcements', icon: Megaphone },
|
||||
{ name: '调用记录', path: '/admin/usage', icon: Clipboard },
|
||||
{ name: '消费记录', path: '/admin/transactions', icon: Clipboard },
|
||||
{ name: '充值记录', path: '/admin/recharge-records', icon: CreditCard },
|
||||
{ name: '购买记录', path: '/admin/purchase-records', icon: ShoppingCart },
|
||||
{ name: '发票管理', path: '/admin/invoices', icon: Wallet },
|
||||
{ name: '组件管理', path: '/admin/ui-components', icon: Cube }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return baseMenuItems
|
||||
}
|
||||
|
||||
// 需要企业认证的页面路径列表
|
||||
export const requiresCertificationPaths = [
|
||||
'/finance/wallet',
|
||||
'/finance/recharge-records',
|
||||
'/finance/purchase-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/purchase-records': {
|
||||
title: '购买记录',
|
||||
description: '为了查看完整的购买记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
||||
},
|
||||
'/finance/transactions': {
|
||||
title: '消费记录',
|
||||
description: '为了查看完整的消费记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
||||
},
|
||||
'/apis/usage': {
|
||||
title: 'API调用记录',
|
||||
description: '为了查看完整的API调用记录,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
||||
},
|
||||
'/apis/whitelist': {
|
||||
title: '白名单管理',
|
||||
description: '为了管理API访问白名单,请先完成企业入驻认证。认证成功后我们将赠送您一定的调用额度!'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requiresCertification: true,
|
||||
...pageConfig[path]
|
||||
}
|
||||
}
|
||||
296
src/layouts/AuthLayout.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div class="auth-layout min-h-screen w-full flex items-center justify-center relative">
|
||||
<!-- color4bg 需要绑定带 id 的容器(包内用 getElementById) -->
|
||||
<div id="auth-color4bg" ref="bgContainer" class="auth-bg-canvas" aria-hidden="true"></div>
|
||||
<div class="auth-bg-mask"></div>
|
||||
|
||||
<div class="relative z-10 w-full auth-content-shell mx-4">
|
||||
<section class="auth-panel">
|
||||
<aside class="brand-side">
|
||||
<div class="brand-grid"></div>
|
||||
<div class="brand-glow brand-glow-top"></div>
|
||||
<div class="brand-glow brand-glow-bottom"></div>
|
||||
<div class="brand-content">
|
||||
<img src="/logo.png" alt="海宇数据" class="brand-logo" />
|
||||
<h2>海宇数据</h2>
|
||||
<p class="brand-desc">
|
||||
海宇数据服务平台,提供专业、稳定、高效的数据服务。平台提供 100+ 个接口,文档清晰、易于理解,可快速完成集成与调用。
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="form-side">
|
||||
<div class="form-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<!-- 底部版权 -->
|
||||
<div class="auth-copyright text-center text-xs text-slate-200/80 mt-8 pt-6 border-t border-white/20">
|
||||
© 2026 海宇数据. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ChaosWavesBg } from 'color4bg'
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
// color4bg 的 dom 必须传 id 字符串(内部使用 getElementById,不能传 Element)
|
||||
const bgContainer = ref(null)
|
||||
let colorBgInstance = null
|
||||
|
||||
onMounted(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
return
|
||||
}
|
||||
void nextTick(() => {
|
||||
if (!document.getElementById('auth-color4bg')) {
|
||||
return
|
||||
}
|
||||
colorBgInstance = new ChaosWavesBg({
|
||||
dom: 'auth-color4bg',
|
||||
colors: ['#007FFE', '#3099FE', '#60B2FE', '#90CCFE', '#C0E5FE', '#F0FFFE'],
|
||||
loop: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (colorBgInstance) {
|
||||
colorBgInstance.destroy()
|
||||
colorBgInstance = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
overflow: hidden;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.auth-bg-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auth-bg-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
background:
|
||||
radial-gradient(circle at 20% 25%, rgba(15, 23, 42, 0.06) 0%, rgba(15, 23, 42, 0.22) 60%, rgba(15, 23, 42, 0.34) 100%),
|
||||
linear-gradient(120deg, rgba(15, 23, 42, 0.08) 0%, rgba(15, 23, 42, 0.24) 100%);
|
||||
}
|
||||
|
||||
.auth-content-shell {
|
||||
width: min(1120px, 100%);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
min-height: 620px;
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 46%) minmax(360px, 54%);
|
||||
box-shadow:
|
||||
0 24px 45px rgba(15, 23, 42, 0.16),
|
||||
0 8px 22px rgba(15, 23, 42, 0.12);
|
||||
border: 1px solid rgba(226, 232, 240, 0.7);
|
||||
}
|
||||
|
||||
.brand-side {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 56px 44px;
|
||||
color: #eef6ff;
|
||||
background:
|
||||
radial-gradient(circle at 8% 10%, rgba(196, 233, 255, 0.38) 0%, rgba(196, 233, 255, 0.06) 42%, transparent 68%),
|
||||
linear-gradient(160deg, #2a6fb1 0%, #3d82c0 52%, #5697cf 100%);
|
||||
}
|
||||
|
||||
.brand-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.2) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.2) 1px, transparent 1px);
|
||||
background-size: 32px 32px;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.brand-glow {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.brand-glow-top {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
top: -90px;
|
||||
right: -65px;
|
||||
background: radial-gradient(circle, rgba(144, 209, 255, 0.38) 0%, rgba(26, 80, 138, 0.05) 70%, transparent 100%);
|
||||
}
|
||||
|
||||
.brand-glow-bottom {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
left: -90px;
|
||||
bottom: -100px;
|
||||
background: radial-gradient(circle, rgba(128, 191, 255, 0.28) 0%, rgba(30, 81, 135, 0.06) 68%, transparent 100%);
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 20px;
|
||||
max-width: 320px;
|
||||
height: 100%;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 88px;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
filter: brightness(1.1) drop-shadow(0 8px 16px rgba(255, 255, 255, 0.18));
|
||||
}
|
||||
|
||||
.brand-content h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 3.6vw, 42px);
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.03em;
|
||||
font-weight: 700;
|
||||
color: #f9fcff;
|
||||
}
|
||||
|
||||
.brand-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: rgba(244, 249, 255, 0.96);
|
||||
}
|
||||
|
||||
.form-side {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 56px 56px 40px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
|
||||
}
|
||||
|
||||
.form-content {
|
||||
width: min(460px, 100%);
|
||||
}
|
||||
|
||||
.auth-copyright {
|
||||
text-shadow: 0 2px 8px rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
/* 性能优化:减少动画效果 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
.auth-bg-canvas {
|
||||
background: linear-gradient(160deg, #0f2748 0%, #1a4a6e 50%, #2a5f8a 100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 性能优化:低端设备降级 */
|
||||
@media (max-width: 640px) {
|
||||
.min-h-\[480px\] {
|
||||
min-height: 320px !important;
|
||||
}
|
||||
|
||||
.px-8 {
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
|
||||
.auth-content-shell {
|
||||
padding-block: 1rem;
|
||||
}
|
||||
|
||||
.mx-4 {
|
||||
margin-left: 0.5rem !important;
|
||||
margin-right: 0.5rem !important;
|
||||
}
|
||||
|
||||
.form-side {
|
||||
padding-inline: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 性能优化:低配置设备可直接静态遮罩 */
|
||||
@media (max-width: 768px),
|
||||
(max-device-pixel-ratio: 1) {
|
||||
.auth-layout {
|
||||
background: #111827 !important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.auth-panel {
|
||||
min-height: 560px;
|
||||
grid-template-columns: 42% 58%;
|
||||
}
|
||||
|
||||
.form-side {
|
||||
padding-inline: 38px;
|
||||
}
|
||||
|
||||
.brand-content h2 {
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.auth-panel {
|
||||
min-height: auto;
|
||||
grid-template-columns: 1fr;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.brand-side {
|
||||
min-height: 220px;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.brand-content {
|
||||
max-width: none;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.brand-content h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.form-side {
|
||||
padding: 32px 22px 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||