first commit

This commit is contained in:
2026-04-20 16:42:28 +08:00
commit c77780fa0e
365 changed files with 41599 additions and 0 deletions

9
src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import { useAppBootstrap } from '@/composables/useAppBootstrap'
const { bootstrap } = useAppBootstrap()
onLaunch(async () => {
await bootstrap()
})
</script>

33
src/api/index.ts Normal file
View File

@@ -0,0 +1,33 @@
import { request } from '@/utils/request'
export function fetchUserDetail() {
return request<{ userInfo: Record<string, any> }>({ url: '/user/detail' })
}
export function fetchAgentInfo() {
return request<Record<string, any>>({ url: '/agent/info' })
}
export function fetchHistoryList(params: Record<string, unknown>) {
return request<{ list: Record<string, any>[] }>({
url: '/report/history',
method: 'POST',
data: params,
})
}
export function submitLogin(params: { mobile: string, captcha: string }) {
return request<{ token: string }>({
url: '/auth/login',
method: 'POST',
data: params,
})
}
export function submitInquire(params: Record<string, unknown>) {
return request<{ orderNo: string }>({
url: '/report/inquire',
method: 'POST',
data: params,
})
}

752
src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,752 @@
/* 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 ZoomAdapter: typeof import('./utils/zoomAdapter.js')['ZoomAdapter']
const aesDecrypt: typeof import('./utils/crypto.js')['aesDecrypt']
const aesEncrypt: typeof import('./utils/crypto.js')['aesEncrypt']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const chatCrypto: typeof import('./utils/chatCrypto.js')['default']
const chatEncrypt: typeof import('./utils/chatEncrypt.js')['default']
const clearAuthStorage: typeof import('./utils/storage')['clearAuthStorage']
const clearToken: typeof import('./utils/storage')['clearToken']
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 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 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 debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const ensureCurrentPageAccess: typeof import('./composables/useNavigationAuthGuard')['ensureCurrentPageAccess']
const ensurePageAccessByUrl: typeof import('./composables/useNavigationAuthGuard')['ensurePageAccessByUrl']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getAgentInfo: typeof import('./utils/storage')['getAgentInfo']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentPageMeta: typeof import('./composables/uni-router')['getCurrentPageMeta']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const getCurrentUniRoute: typeof import('./composables/uni-router')['getCurrentUniRoute']
const getLayoutPageTitle: typeof import('./composables/uni-router')['getLayoutPageTitle']
const getPageTitleByRoute: typeof import('./composables/uni-router')['getPageTitleByRoute']
const getToken: typeof import('./utils/storage')['getToken']
const getUserInfo: typeof import('./utils/storage')['getUserInfo']
const getWebPathForNotification: typeof import('./composables/uni-router')['getWebPathForNotification']
const h: typeof import('vue')['h']
const handlePosterRenderMergeDone: typeof import('./utils/posterRenderMergeBridge')['handlePosterRenderMergeDone']
const handlePosterRenderMergeFailed: typeof import('./utils/posterRenderMergeBridge')['handlePosterRenderMergeFailed']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const installNavigationAuthGuard: typeof import('./composables/useNavigationAuthGuard')['installNavigationAuthGuard']
const isDefined: typeof import('@vueuse/core')['isDefined']
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 makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const markRaw: typeof import('vue')['markRaw']
const navigateLogin: typeof import('./utils/navigate')['navigateLogin']
const navigateTo: typeof import('./utils/navigate')['navigateTo']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onAddToFavorites: typeof import('@dcloudio/uni-app')['onAddToFavorites']
const onBackPress: typeof import('@dcloudio/uni-app')['onBackPress']
const onBeforeMount: typeof import('vue')['onBeforeMount']
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 onError: typeof import('@dcloudio/uni-app')['onError']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onHide: typeof import('@dcloudio/uni-app')['onHide']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLaunch: typeof import('@dcloudio/uni-app')['onLaunch']
const onLoad: typeof import('@dcloudio/uni-app')['onLoad']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onNavigationBarButtonTap: typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']
const onNavigationBarSearchInputChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']
const onNavigationBarSearchInputClicked: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']
const onNavigationBarSearchInputConfirmed: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']
const onNavigationBarSearchInputFocusChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']
const onPageNotFound: typeof import('@dcloudio/uni-app')['onPageNotFound']
const onPageScroll: typeof import('@dcloudio/uni-app')['onPageScroll']
const onPullDownRefresh: typeof import('@dcloudio/uni-app')['onPullDownRefresh']
const onReachBottom: typeof import('@dcloudio/uni-app')['onReachBottom']
const onReady: typeof import('@dcloudio/uni-app')['onReady']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onResize: typeof import('@dcloudio/uni-app')['onResize']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onShareAppMessage: typeof import('@dcloudio/uni-app')['onShareAppMessage']
const onShareTimeline: typeof import('@dcloudio/uni-app')['onShareTimeline']
const onShow: typeof import('@dcloudio/uni-app')['onShow']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onTabItemTap: typeof import('@dcloudio/uni-app')['onTabItemTap']
const onThemeChange: typeof import('@dcloudio/uni-app')['onThemeChange']
const onUnhandledRejection: typeof import('@dcloudio/uni-app')['onUnhandledRejection']
const onUnload: typeof import('@dcloudio/uni-app')['onUnload']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const openCustomerService: typeof import('./composables/useCustomerService')['openCustomerService']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
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 readLocalFileAsBase64: typeof import('./utils/appLocalFile')['readLocalFileAsBase64']
const readonly: typeof import('vue')['readonly']
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 request: typeof import('./utils/request')['request']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const resolveUserAvatarUrl: typeof import('./utils/avatarUrl')['resolveUserAvatarUrl']
const resolveWebToUni: typeof import('./composables/uni-router')['resolveWebToUni']
const setAgentInfo: typeof import('./utils/storage')['setAgentInfo']
const setAuthSession: typeof import('./utils/storage')['setAuthSession']
const setPosterMergePending: typeof import('./utils/posterRenderMergeBridge')['setPosterMergePending']
const setToken: typeof import('./utils/storage')['setToken']
const setUserInfo: typeof import('./utils/storage')['setUserInfo']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
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 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 unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAgentStore: typeof import('./stores/agentStore')['useAgentStore']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useApiFetch: typeof import('./composables/useApiFetch')['default']
const useAppBootstrap: typeof import('./composables/useAppBootstrap')['useAppBootstrap']
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 useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useAuthGuard: typeof import('./composables/useAuthGuard')['useAuthGuard']
const useAuthStore: typeof import('./stores/auth')['useAuthStore']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
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 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 useCount: typeof import('./composables/useCount')['useCount']
const useCountdown: typeof import('@vueuse/core')['useCountdown']
const useCounter: typeof import('@vueuse/core')['useCounter']
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 useDialogStore: typeof import('./stores/dialogStore')['useDialogStore']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEnv: typeof import('./composables/useEnv.js')['useEnv']
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 useHotUpdate: typeof import('./composables/useHotUpdate')['useHotUpdate']
const useHttp: typeof import('./composables/useHttp.js')['useHttp']
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 useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const 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 useQuery: typeof import('./composables/useQuery')['useQuery']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useReportWebview: typeof import('./composables/useReportWebview')['useReportWebview']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRiskNotifier: typeof import('./composables/useRiskNotifier.js')['useRiskNotifier']
const useRoute: typeof import('./composables/uni-router')['useRoute']
const useRouter: typeof import('./composables/uni-router')['useRouter']
const useSEO: typeof import('./composables/useSEO.js')['useSEO']
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 useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useUserStore: typeof import('./stores/userStore')['useUserStore']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
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 useWebView: typeof import('./composables/useWebView.js')['useWebView']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWeixinShare: typeof import('./composables/useWeixinShare.js')['useWeixinShare']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const useZoomAdapter: typeof import('./composables/useZoomAdapter.js')['useZoomAdapter']
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']
const writePngBase64ToLocal: typeof import('./utils/appLocalFile')['writePngBase64ToLocal']
const zoomAdapter: typeof import('./utils/zoomAdapter.js')['default']
}
// 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 { ApiEnvelope } from './composables/useApiFetch'
import('./composables/useApiFetch')
// @ts-ignore
export type { AppVersionPayload } from './composables/useHotUpdate'
import('./composables/useHotUpdate')
// @ts-ignore
export type { ApiResponse } from './utils/request'
import('./utils/request')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly ZoomAdapter: UnwrapRef<typeof import('./utils/zoomAdapter.js')['ZoomAdapter']>
readonly aesDecrypt: UnwrapRef<typeof import('./utils/crypto.js')['aesDecrypt']>
readonly aesEncrypt: UnwrapRef<typeof import('./utils/crypto.js')['aesEncrypt']>
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
readonly autoResetRef: UnwrapRef<typeof import('@vueuse/core')['autoResetRef']>
readonly chatCrypto: UnwrapRef<typeof import('./utils/chatCrypto.js')['default']>
readonly chatEncrypt: UnwrapRef<typeof import('./utils/chatEncrypt.js')['default']>
readonly clearAuthStorage: UnwrapRef<typeof import('./utils/storage')['clearAuthStorage']>
readonly clearToken: UnwrapRef<typeof import('./utils/storage')['clearToken']>
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 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 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 debouncedRef: UnwrapRef<typeof import('@vueuse/core')['debouncedRef']>
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly ensureCurrentPageAccess: UnwrapRef<typeof import('./composables/useNavigationAuthGuard')['ensureCurrentPageAccess']>
readonly ensurePageAccessByUrl: UnwrapRef<typeof import('./composables/useNavigationAuthGuard')['ensurePageAccessByUrl']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly getAgentInfo: UnwrapRef<typeof import('./utils/storage')['getAgentInfo']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentPageMeta: UnwrapRef<typeof import('./composables/uni-router')['getCurrentPageMeta']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly getCurrentUniRoute: UnwrapRef<typeof import('./composables/uni-router')['getCurrentUniRoute']>
readonly getLayoutPageTitle: UnwrapRef<typeof import('./composables/uni-router')['getLayoutPageTitle']>
readonly getPageTitleByRoute: UnwrapRef<typeof import('./composables/uni-router')['getPageTitleByRoute']>
readonly getToken: UnwrapRef<typeof import('./utils/storage')['getToken']>
readonly getUserInfo: UnwrapRef<typeof import('./utils/storage')['getUserInfo']>
readonly getWebPathForNotification: UnwrapRef<typeof import('./composables/uni-router')['getWebPathForNotification']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly handlePosterRenderMergeDone: UnwrapRef<typeof import('./utils/posterRenderMergeBridge')['handlePosterRenderMergeDone']>
readonly handlePosterRenderMergeFailed: UnwrapRef<typeof import('./utils/posterRenderMergeBridge')['handlePosterRenderMergeFailed']>
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly installNavigationAuthGuard: UnwrapRef<typeof import('./composables/useNavigationAuthGuard')['installNavigationAuthGuard']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
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 makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly navigateLogin: UnwrapRef<typeof import('./utils/navigate')['navigateLogin']>
readonly navigateTo: UnwrapRef<typeof import('./utils/navigate')['navigateTo']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onAddToFavorites: UnwrapRef<typeof import('@dcloudio/uni-app')['onAddToFavorites']>
readonly onBackPress: UnwrapRef<typeof import('@dcloudio/uni-app')['onBackPress']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
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 onError: UnwrapRef<typeof import('@dcloudio/uni-app')['onError']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onHide: UnwrapRef<typeof import('@dcloudio/uni-app')['onHide']>
readonly onKeyStroke: UnwrapRef<typeof import('@vueuse/core')['onKeyStroke']>
readonly onLaunch: UnwrapRef<typeof import('@dcloudio/uni-app')['onLaunch']>
readonly onLoad: UnwrapRef<typeof import('@dcloudio/uni-app')['onLoad']>
readonly onLongPress: UnwrapRef<typeof import('@vueuse/core')['onLongPress']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onNavigationBarButtonTap: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']>
readonly onNavigationBarSearchInputChanged: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']>
readonly onNavigationBarSearchInputClicked: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']>
readonly onNavigationBarSearchInputConfirmed: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']>
readonly onNavigationBarSearchInputFocusChanged: UnwrapRef<typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']>
readonly onPageNotFound: UnwrapRef<typeof import('@dcloudio/uni-app')['onPageNotFound']>
readonly onPageScroll: UnwrapRef<typeof import('@dcloudio/uni-app')['onPageScroll']>
readonly onPullDownRefresh: UnwrapRef<typeof import('@dcloudio/uni-app')['onPullDownRefresh']>
readonly onReachBottom: UnwrapRef<typeof import('@dcloudio/uni-app')['onReachBottom']>
readonly onReady: UnwrapRef<typeof import('@dcloudio/uni-app')['onReady']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onResize: UnwrapRef<typeof import('@dcloudio/uni-app')['onResize']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onShareAppMessage: UnwrapRef<typeof import('@dcloudio/uni-app')['onShareAppMessage']>
readonly onShareTimeline: UnwrapRef<typeof import('@dcloudio/uni-app')['onShareTimeline']>
readonly onShow: UnwrapRef<typeof import('@dcloudio/uni-app')['onShow']>
readonly onStartTyping: UnwrapRef<typeof import('@vueuse/core')['onStartTyping']>
readonly onTabItemTap: UnwrapRef<typeof import('@dcloudio/uni-app')['onTabItemTap']>
readonly onThemeChange: UnwrapRef<typeof import('@dcloudio/uni-app')['onThemeChange']>
readonly onUnhandledRejection: UnwrapRef<typeof import('@dcloudio/uni-app')['onUnhandledRejection']>
readonly onUnload: UnwrapRef<typeof import('@dcloudio/uni-app')['onUnload']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
readonly openCustomerService: UnwrapRef<typeof import('./composables/useCustomerService')['openCustomerService']>
readonly pausableWatch: UnwrapRef<typeof import('@vueuse/core')['pausableWatch']>
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 readLocalFileAsBase64: UnwrapRef<typeof import('./utils/appLocalFile')['readLocalFileAsBase64']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
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 request: UnwrapRef<typeof import('./utils/request')['request']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly resolveUserAvatarUrl: UnwrapRef<typeof import('./utils/avatarUrl')['resolveUserAvatarUrl']>
readonly resolveWebToUni: UnwrapRef<typeof import('./composables/uni-router')['resolveWebToUni']>
readonly setAgentInfo: UnwrapRef<typeof import('./utils/storage')['setAgentInfo']>
readonly setAuthSession: UnwrapRef<typeof import('./utils/storage')['setAuthSession']>
readonly setPosterMergePending: UnwrapRef<typeof import('./utils/posterRenderMergeBridge')['setPosterMergePending']>
readonly setToken: UnwrapRef<typeof import('./utils/storage')['setToken']>
readonly setUserInfo: UnwrapRef<typeof import('./utils/storage')['setUserInfo']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
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 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 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 unref: UnwrapRef<typeof import('vue')['unref']>
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
readonly useAgentStore: UnwrapRef<typeof import('./stores/agentStore')['useAgentStore']>
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
readonly useApiFetch: UnwrapRef<typeof import('./composables/useApiFetch')['default']>
readonly useAppBootstrap: UnwrapRef<typeof import('./composables/useAppBootstrap')['useAppBootstrap']>
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 useAuthGuard: UnwrapRef<typeof import('./composables/useAuthGuard')['useAuthGuard']>
readonly useAuthStore: UnwrapRef<typeof import('./stores/auth')['useAuthStore']>
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 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 useCount: UnwrapRef<typeof import('./composables/useCount')['useCount']>
readonly useCountdown: UnwrapRef<typeof import('@vueuse/core')['useCountdown']>
readonly useCounter: UnwrapRef<typeof import('@vueuse/core')['useCounter']>
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 useDialogStore: UnwrapRef<typeof import('./stores/dialogStore')['useDialogStore']>
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 useEnv: UnwrapRef<typeof import('./composables/useEnv.js')['useEnv']>
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 useHotUpdate: UnwrapRef<typeof import('./composables/useHotUpdate')['useHotUpdate']>
readonly useHttp: UnwrapRef<typeof import('./composables/useHttp.js')['useHttp']>
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 useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
readonly useMouseInElement: UnwrapRef<typeof import('@vueuse/core')['useMouseInElement']>
readonly useMousePressed: UnwrapRef<typeof import('@vueuse/core')['useMousePressed']>
readonly useMutationObserver: UnwrapRef<typeof import('@vueuse/core')['useMutationObserver']>
readonly useNavigatorLanguage: UnwrapRef<typeof import('@vueuse/core')['useNavigatorLanguage']>
readonly useNetwork: UnwrapRef<typeof import('@vueuse/core')['useNetwork']>
readonly useNow: UnwrapRef<typeof import('@vueuse/core')['useNow']>
readonly useObjectUrl: UnwrapRef<typeof import('@vueuse/core')['useObjectUrl']>
readonly useOffsetPagination: UnwrapRef<typeof import('@vueuse/core')['useOffsetPagination']>
readonly useOnline: UnwrapRef<typeof import('@vueuse/core')['useOnline']>
readonly usePageLeave: UnwrapRef<typeof import('@vueuse/core')['usePageLeave']>
readonly useParallax: UnwrapRef<typeof import('@vueuse/core')['useParallax']>
readonly useParentElement: UnwrapRef<typeof import('@vueuse/core')['useParentElement']>
readonly usePerformanceObserver: UnwrapRef<typeof import('@vueuse/core')['usePerformanceObserver']>
readonly usePermission: UnwrapRef<typeof import('@vueuse/core')['usePermission']>
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePreferredReducedTransparency: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedTransparency']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly useQuery: UnwrapRef<typeof import('./composables/useQuery')['useQuery']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useReportWebview: UnwrapRef<typeof import('./composables/useReportWebview')['useReportWebview']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
readonly useRiskNotifier: UnwrapRef<typeof import('./composables/useRiskNotifier.js')['useRiskNotifier']>
readonly useRoute: UnwrapRef<typeof import('./composables/uni-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('./composables/uni-router')['useRouter']>
readonly useSEO: UnwrapRef<typeof import('./composables/useSEO.js')['useSEO']>
readonly useSSRWidth: UnwrapRef<typeof import('@vueuse/core')['useSSRWidth']>
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
readonly useScroll: UnwrapRef<typeof import('@vueuse/core')['useScroll']>
readonly useScrollLock: UnwrapRef<typeof import('@vueuse/core')['useScrollLock']>
readonly useSessionStorage: UnwrapRef<typeof import('@vueuse/core')['useSessionStorage']>
readonly useShare: UnwrapRef<typeof import('@vueuse/core')['useShare']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly useSorted: UnwrapRef<typeof import('@vueuse/core')['useSorted']>
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
readonly useTemplateRefsList: UnwrapRef<typeof import('@vueuse/core')['useTemplateRefsList']>
readonly useTextDirection: UnwrapRef<typeof import('@vueuse/core')['useTextDirection']>
readonly useTextSelection: UnwrapRef<typeof import('@vueuse/core')['useTextSelection']>
readonly useTextareaAutosize: UnwrapRef<typeof import('@vueuse/core')['useTextareaAutosize']>
readonly useThrottle: UnwrapRef<typeof import('@vueuse/core')['useThrottle']>
readonly useThrottleFn: UnwrapRef<typeof import('@vueuse/core')['useThrottleFn']>
readonly useThrottledRefHistory: UnwrapRef<typeof import('@vueuse/core')['useThrottledRefHistory']>
readonly useTimeAgo: UnwrapRef<typeof import('@vueuse/core')['useTimeAgo']>
readonly useTimeout: UnwrapRef<typeof import('@vueuse/core')['useTimeout']>
readonly useTimeoutFn: UnwrapRef<typeof import('@vueuse/core')['useTimeoutFn']>
readonly useTimeoutPoll: UnwrapRef<typeof import('@vueuse/core')['useTimeoutPoll']>
readonly useTimestamp: UnwrapRef<typeof import('@vueuse/core')['useTimestamp']>
readonly useTitle: UnwrapRef<typeof import('@vueuse/core')['useTitle']>
readonly useToNumber: UnwrapRef<typeof import('@vueuse/core')['useToNumber']>
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
readonly useUserStore: UnwrapRef<typeof import('./stores/userStore')['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 useWebView: UnwrapRef<typeof import('./composables/useWebView.js')['useWebView']>
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
readonly useWeixinShare: UnwrapRef<typeof import('./composables/useWeixinShare.js')['useWeixinShare']>
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 useZoomAdapter: UnwrapRef<typeof import('./composables/useZoomAdapter.js')['useZoomAdapter']>
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']>
readonly writePngBase64ToLocal: UnwrapRef<typeof import('./utils/appLocalFile')['writePngBase64ToLocal']>
readonly zoomAdapter: UnwrapRef<typeof import('./utils/zoomAdapter.js')['default']>
}
}

50
src/components.d.ts vendored Normal file
View File

@@ -0,0 +1,50 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by vite-plugin-uni-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
AccountCancelAgreement: typeof import('./components/AccountCancelAgreement.vue')['default']
AgentApplicationForm: typeof import('./components/AgentApplicationForm.vue')['default']
BindPhoneDialog: typeof import('./components/BindPhoneDialog.vue')['default']
GaugeChart: typeof import('./components/GaugeChart.vue')['default']
ImageSaveGuide: typeof import('./components/ImageSaveGuide.vue')['default']
InquireForm: typeof import('./components/InquireForm.vue')['default']
LButtonGroup: typeof import('./components/LButtonGroup.vue')['default']
LEmpty: typeof import('./components/LEmpty.vue')['default']
LoginDialog: typeof import('./components/LoginDialog.vue')['default']
LRemark: typeof import('./components/LRemark.vue')['default']
LTable: typeof import('./components/LTable.vue')['default']
LTitle: typeof import('./components/LTitle.vue')['default']
Payment: typeof import('./components/Payment.vue')['default']
PriceInputPopup: typeof import('./components/PriceInputPopup.vue')['default']
QRcode: typeof import('./components/QRcode.vue')['default']
RealNameAuthDialog: typeof import('./components/RealNameAuthDialog.vue')['default']
SectionTitle: typeof import('./components/SectionTitle.vue')['default']
ShareReportButton: typeof import('./components/ShareReportButton.vue')['default']
StyledTabs: typeof import('./components/StyledTabs.vue')['default']
TitleBanner: typeof import('./components/TitleBanner.vue')['default']
VerificationCard: typeof import('./components/VerificationCard.vue')['default']
VipBanner: typeof import('./components/VipBanner.vue')['default']
WdButton: typeof import('wot-design-uni/components/wd-button/wd-button.vue')['default']
WdCell: typeof import('wot-design-uni/components/wd-cell/wd-cell.vue')['default']
WdCellGroup: typeof import('wot-design-uni/components/wd-cell-group/wd-cell-group.vue')['default']
WdCheckbox: typeof import('wot-design-uni/components/wd-checkbox/wd-checkbox.vue')['default']
WdColPicker: typeof import('wot-design-uni/components/wd-col-picker/wd-col-picker.vue')['default']
WdDivider: typeof import('wot-design-uni/components/wd-divider/wd-divider.vue')['default']
WdForm: typeof import('wot-design-uni/components/wd-form/wd-form.vue')['default']
WdIcon: typeof import('wot-design-uni/components/wd-icon/wd-icon.vue')['default']
WdInput: typeof import('wot-design-uni/components/wd-input/wd-input.vue')['default']
WdNavbar: typeof import('wot-design-uni/components/wd-navbar/wd-navbar.vue')['default']
WdPagination: typeof import('wot-design-uni/components/wd-pagination/wd-pagination.vue')['default']
WdPicker: typeof import('wot-design-uni/components/wd-picker/wd-picker.vue')['default']
WdPopup: typeof import('wot-design-uni/components/wd-popup/wd-popup.vue')['default']
WdRadio: typeof import('wot-design-uni/components/wd-radio/wd-radio.vue')['default']
WdRadioGroup: typeof import('wot-design-uni/components/wd-radio-group/wd-radio-group.vue')['default']
WdTabbar: typeof import('wot-design-uni/components/wd-tabbar/wd-tabbar.vue')['default']
WdTabbarItem: typeof import('wot-design-uni/components/wd-tabbar-item/wd-tabbar-item.vue')['default']
}
}

View File

@@ -0,0 +1,46 @@
<template>
<view class="box-border p-4 text-sm text-gray-800 leading-relaxed">
<view class="mb-4 text-center text-lg text-gray-900 font-semibold">
帐号注销协议
</view>
<text class="mb-3 block text-justify">
您在申请注销流程中点击同意前应当认真阅读帐号注销协议以下简称本协议特别提醒您当您成功提交注销申请后即表示您已充分阅读理解并接受本协议的全部内容阅读本协议的过程中如果您不同意相关任何条款请您立即停止帐号注销程序如您对本协议有任何疑问可联系我们的客服
</text>
<text class="mb-3 block text-justify">
1. 如果您仍欲继续注销帐号您的帐号需同时满足以下条件
</text>
<text class="mb-2 ml-2 block text-justify">
1帐号不在处罚状态中且能正常登录
</text>
<text class="mb-3 ml-2 block text-justify">
2帐号最近一个月内并无修改密码修改关联手机绑定手机记录
</text>
<text class="mb-3 block text-justify">
2. 您应确保您有权决定该帐号的注销事宜不侵犯任何第三方的合法权益如因此引发任何争议由您自行承担
</text>
<text class="mb-3 block text-justify">
3. 您理解并同意账号注销后我们无法协助您重新恢复前述服务请您在申请注销前自行备份您欲保留的本帐号信息和数据
</text>
<text class="mb-3 block text-justify">
4. 帐号注销后已绑定的手机号认证信息将会消失且无法注册
</text>
<text class="mb-3 block text-justify">
5. 注销帐号后您将无法再使用本帐号也将无法找回您帐号中及与帐号相关的任何内容或信息包括但不限于
</text>
<text class="mb-2 ml-2 block text-justify">
1您将无法继续使用该帐号进行登录
</text>
<text class="mb-2 ml-2 block text-justify">
2您帐号的个人资料和历史信息包含昵称头像消费记录查询报告等都将无法找回
</text>
<text class="mb-3 ml-2 block text-justify">
3您理解并同意注销帐号后您曾获得的充值余额贝壳币及其他虚拟财产等将视为您自愿主动放弃无法继续使用由此引起一切纠纷由您自行处理我们不承担任何责任
</text>
<text class="mb-3 block text-justify">
6. 在帐号注销期间如果您的帐号被他人投诉被国家机关调查或者正处于诉讼仲裁程序中我们有权自行终止您的帐号注销程序而无需另行得到您的同意
</text>
<text class="mb-3 block text-justify">
7. 请注意注销您的帐号并不代表本帐号注销前的帐号行为和相关责任得到豁免或减轻
</text>
</view>
</template>

View File

@@ -0,0 +1,285 @@
<script setup>
import pcaData from '@/static/pca.json'
const props = defineProps({
ancestor: {
type: String,
required: true,
},
isLoggedIn: {
type: Boolean,
default: false,
},
userMobile: {
type: String,
default: '',
},
isSelf: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['submit', 'close'])
const show = defineModel('show')
const { ancestor, isLoggedIn, userMobile, isSelf } = toRefs(props)
const form = ref({
region: '',
mobile: '',
code: '',
})
const region = ref([])
const loadingSms = ref(false)
const isCountingDown = ref(false)
const isAgreed = ref(false)
const countdown = ref(60)
const mobileReadonly = computed(() => Boolean(isLoggedIn.value && userMobile.value))
function buildPcaColumns() {
return Object.entries(pcaData).map(([provinceName, cityMap]) => ({
label: provinceName,
value: provinceName,
children: Object.entries(cityMap).map(([cityName, districtList]) => ({
label: cityName,
value: cityName,
children: districtList.map(districtName => ({
label: districtName,
value: districtName,
})),
})),
}))
}
const provinceOptions = buildPcaColumns()
const columns = ref([provinceOptions])
function handleColumnChange({ selectedItem, resolve, finish }) {
const children = selectedItem?.children
if (children?.length) {
resolve(children)
return
}
finish()
}
function displayFormat(selectedItems) {
if (!selectedItems?.length)
return ''
return selectedItems.map(item => item.label).join('/')
}
function handleRegionConfirm({ value, selectedItems }) {
region.value = value || []
form.value.region = selectedItems?.map(item => item.label).join('/') || ''
}
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(form.value.mobile)
})
async function getSmsCode() {
if (!form.value.mobile) {
showToast({ message: '请输入手机号' })
return
}
if (!isPhoneNumberValid.value) {
showToast({ message: '手机号格式不正确' })
return
}
loadingSms.value = true
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: form.value.mobile, actionType: 'agentApply', captchaVerifyParam: '' })
.json()
loadingSms.value = false
if (!error.value && data.value?.code === 200) {
showToast({ message: '获取成功' })
startCountdown()
}
else {
showToast(data.value?.msg || '发送失败')
}
}
let timer = null
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
function submit() {
// 校验表单字段
if (!form.value.region) {
showToast({ message: '请选择地区' })
return
}
if (!form.value.mobile) {
showToast({ message: '请输入手机号' })
return
}
if (!isPhoneNumberValid.value) {
showToast({ message: '手机号格式不正确' })
return
}
// 如果不是自己申请,则需要验证码
if (!isSelf.value && !form.value.code) {
showToast({ message: '请输入验证码' })
return
}
if (!isAgreed.value) {
showToast({ message: '请先阅读并同意用户协议及相关条款' })
return
}
// 触发父组件提交申请
emit('submit', form.value)
}
const maskName = computed(() => {
return (name) => {
return `${name.substring(0, 3)}****${name.substring(7)}`
}
})
function closePopup() {
emit('close')
}
function toUserAgreement() {
uni.navigateTo({ url: '/pages/user-agreement' })
}
function toAgentManageAgreement() {
uni.navigateTo({ url: '/pages/agent-manage-agreement' })
}
watch(
() => [isLoggedIn.value, userMobile.value],
([loggedIn, mobile]) => {
if (loggedIn && mobile) {
form.value.mobile = mobile
}
},
{ immediate: true },
)
</script>
<template>
<wd-popup v-model="show" destroy-on-close round position="bottom">
<view class="h-12 flex items-center justify-center font-semibold"
style="background-color: var(--van-theme-primary-light); color: var(--van-theme-primary);">
成为代理
</view>
<view v-if="ancestor" class="my-2 text-center text-xs" style="color: var(--van-text-color-2);">
{{ maskName(ancestor) }}邀您成为赤眉代理方
</view>
<view class="p-4">
<wd-col-picker v-model="region" class="agent-form-field" label-width="42px" label="地区" placeholder="请选择地区"
:columns="columns" :column-change="handleColumnChange" :display-format="displayFormat" :align-right="false"
custom-value-class="agent-col-picker-value" @confirm="handleRegionConfirm" />
<wd-input v-model="form.mobile" class="agent-form-field" label-width="42px" label="手机号" name="mobile"
placeholder="请输入手机号" :align-right="false" :readonly="mobileReadonly" :disabled="mobileReadonly" />
<!-- 获取验证码按钮 -->
<wd-input v-model="form.code" class="agent-form-field" label-width="42px" label="验证码" name="code"
placeholder="请输入验证码" :align-right="false" use-suffix-slot>
<template #suffix>
<button class="ml-2 flex-shrink-0 rounded-lg px-2 py-1 text-sm font-bold transition duration-300" :class="isCountingDown || !isPhoneNumberValid
? 'cursor-not-allowed bg-gray-300 text-gray-500'
: 'text-white hover:opacity-90'" :style="isCountingDown || !isPhoneNumberValid
? ''
: 'background-color: var(--van-theme-primary);'" :disabled="isCountingDown || !isPhoneNumberValid"
@click.stop="getSmsCode">
{{
isCountingDown ? `${countdown}s重新获取` : '获取验证码'
}}
</button>
</template>
</wd-input>
<!-- 同意条款的复选框 -->
<view class="p-4">
<view class="flex items-start">
<wd-checkbox v-model="isAgreed" name="agree" icon-size="16px" class="mr-2 flex-shrink-0" />
<view class="text-xs leading-tight" style="color: var(--van-text-color-2);">
我已阅读并同意
<a class="cursor-pointer hover:underline" style="color: var(--van-theme-primary);"
@click="toUserAgreement">用户协议</a>
<a class="cursor-pointer hover:underline" style="color: var(--van-theme-primary);"
@click="toAgentManageAgreement">推广方管理制度协议</a>
<view class="mt-1 text-xs" style="color: var(--van-text-color-2);">
点击勾选即代表您同意上述法律文书的相关条款并签署上述法律文书
</view>
<view class="mt-1 text-xs" style="color: var(--van-text-color-2);">
手机号未在本平台注册账号则申请后将自动生成账号
</view>
</view>
</view>
</view>
<view class="mt-4">
<wd-button type="primary" round block @click="submit">
提交申请
</wd-button>
</view>
<view class="mt-2">
<wd-button type="default" round block @click="closePopup">
取消
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style scoped>
:deep(.agent-form-field .wd-cell__title) {
flex: 0 0 42px !important;
max-width: 42px !important;
}
:deep(.agent-col-picker-value) {
display: flex !important;
justify-content: flex-start !important;
width: 100% !important;
text-align: left !important;
}
:deep(.agent-form-field .wd-cell__value) {
flex: 1;
min-width: 0;
justify-content: flex-start !important;
text-align: left !important;
}
:deep(.agent-form-field .wd-input__inner),
:deep(.agent-form-field .wd-col-picker__cell),
:deep(.agent-form-field .wd-col-picker__value),
:deep(.agent-form-field .wd-col-picker__text),
:deep(.agent-form-field .is-placeholder) {
text-align: left !important;
}
:deep(.agent-form-field .wd-col-picker__value) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,263 @@
<script setup>
import { computed, nextTick, ref } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
import { setAuthSession } from '@/utils/storage'
const emit = defineEmits(['bind-success'])
const router = useRouter()
const dialogStore = useDialogStore()
const agentStore = useAgentStore()
const userStore = useUserStore()
const phoneNumber = ref('')
const verificationCode = ref('')
const isCountingDown = ref(false)
const countdown = ref(60)
const isAgreed = ref(false)
const verificationCodeInputRef = ref(null)
let timer = null
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
// 聚焦状态变量
const phoneFocused = ref(false)
const codeFocused = ref(false)
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(phoneNumber.value)
})
const canBind = computed(() => {
return (
isPhoneNumberValid.value
&& verificationCode.value.length === 6
&& isAgreed.value
)
})
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: phoneNumber.value, actionType: 'bindMobile', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
showToast({ message: '获取成功' })
startCountdown()
nextTick(() => {
verificationCodeInputRef.value?.focus?.()
})
}
else {
showToast(data.value?.msg || '发送失败')
}
}
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
async function handleBind() {
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
if (verificationCode.value.length !== 6) {
showToast({ message: '请输入有效的验证码' })
return
}
if (!isAgreed.value) {
showToast({ message: '请先同意用户协议' })
return
}
const { data, error } = await useApiFetch('/user/bindMobile')
.post({ mobile: phoneNumber.value, code: verificationCode.value })
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
showToast({ message: '绑定成功' })
setAuthSession(data.value.data)
closeDialog()
await Promise.all([
agentStore.fetchAgentStatus(),
userStore.fetchUserInfo(),
])
// 发出绑定成功的事件
emit('bind-success')
}
else {
showToast(data.value.msg)
}
}
}
function closeDialog() {
dialogStore.closeBindPhone()
// 重置表单
phoneNumber.value = ''
verificationCode.value = ''
isAgreed.value = false
if (timer) {
clearInterval(timer)
}
}
function toUserAgreement() {
closeDialog()
router.push(`/userAgreement`)
}
function toPrivacyPolicy() {
closeDialog()
router.push(`/privacyPolicy`)
}
</script>
<template>
<view v-if="dialogStore.showBindPhone">
<wd-popup v-model="dialogStore.showBindPhone" round position="bottom" :style="{ height: '80%' }"
@close="closeDialog">
<view class="bind-phone-dialog">
<view class="title-bar">
<view class="font-bold">
绑定手机号码
</view>
<view class="mt-1 text-sm text-gray-500">
为使用完整功能请绑定手机号码
</view>
<view class="mt-1 text-sm text-gray-500">
如该微信号之前已绑定过手机号请输入已绑定的手机号
</view>
<wd-icon name="cross" class="close-icon" @click="closeDialog" />
</view>
<view class="px-8">
<view class="mb-8 pt-8 text-left">
<view class="flex flex-col items-center">
<image class="h-16 w-16 rounded-full shadow" src="/static/images/logo.png" alt="Logo" />
<view class="mt-4 text-3xl text-slate-700 font-bold">
赤眉
</view>
</view>
</view>
<view class="space-y-5">
<!-- 手机号输入 -->
<view class="input-container bg-blue-300/20" :class="[
phoneFocused ? 'focused' : '',
]">
<input v-model="phoneNumber" class="input-field" type="tel" placeholder="请输入手机号" maxlength="11"
@focus="phoneFocused = true" @blur="phoneFocused = false">
</view>
<!-- 验证码输入 -->
<view class="flex items-center justify-between">
<view class="input-container bg-blue-300/20" :class="[
codeFocused ? 'focused' : '',
]">
<input id="verificationCode" ref="verificationCodeInputRef" v-model="verificationCode"
class="input-field" placeholder="请输入验证码" maxlength="6" @focus="codeFocused = true"
@blur="codeFocused = false">
</view>
<button class="ml-2 flex-shrink-0 rounded-lg px-4 py-2 text-sm font-bold transition duration-300" :class="isCountingDown || !isPhoneNumberValid
? 'cursor-not-allowed bg-gray-300 text-gray-500'
: 'bg-blue-500 text-white hover:bg-blue-600'
" @click="sendVerificationCode">
{{
isCountingDown
? `${countdown}s重新获取`
: "获取验证码"
}}
</button>
</view>
<!-- 协议同意框 -->
<view class="flex items-start space-x-2">
<input v-model="isAgreed" type="checkbox" class="mt-1">
<text class="text-xs text-gray-400 leading-tight">
绑定手机号即代表您已阅读并同意
<a class="cursor-pointer text-blue-400" @click="toUserAgreement">
用户协议
</a>
<a class="cursor-pointer text-blue-400" @click="toPrivacyPolicy">
隐私政策
</a>
</text>
</view>
</view>
<button
class="mt-10 w-full rounded-full bg-blue-500 py-3 text-lg text-white font-bold transition duration-300"
:class="{ 'opacity-50 cursor-not-allowed': !canBind }" @click="handleBind">
确认绑定
</button>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.bind-phone-dialog {
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
background-position: center;
background-size: cover;
height: 100%;
}
.title-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #eee;
}
.close-icon {
font-size: 20px;
color: #666;
cursor: pointer;
}
.input-container {
border: 2px solid rgba(125, 211, 252, 0);
border-radius: 1rem;
transition: duration-200;
}
.input-container.focused {
border: 2px solid #3b82f6;
}
.input-field {
width: 100%;
padding: 1rem;
background: transparent;
border: none;
outline: none;
transition: border-color 0.3s ease;
}
</style>

View File

@@ -0,0 +1,262 @@
<script setup>
import * as echarts from 'echarts'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
score: {
type: Number,
required: true,
},
})
// 根据分数计算风险等级和颜色(分数越高越安全)
const riskLevel = computed(() => {
const score = props.score
if (score >= 75 && score <= 100) {
return {
level: '无任何风险',
color: '#52c41a',
gradient: [
{ offset: 0, color: '#52c41a' },
{ offset: 1, color: '#7fdb42' },
],
}
}
else if (score >= 50 && score < 75) {
return {
level: '风险指数较低',
color: '#faad14',
gradient: [
{ offset: 0, color: '#faad14' },
{ offset: 1, color: '#ffc53d' },
],
}
}
else if (score >= 25 && score < 50) {
return {
level: '风险指数较高',
color: '#fa8c16',
gradient: [
{ offset: 0, color: '#fa8c16' },
{ offset: 1, color: '#ffa940' },
],
}
}
else {
return {
level: '高风险警告',
color: '#f5222d',
gradient: [
{ offset: 0, color: '#f5222d' },
{ offset: 1, color: '#ff4d4f' },
],
}
}
})
// 评分解释文本(分数越高越安全)
const riskDescription = computed(() => {
const score = props.score
if (score >= 75 && score <= 100) {
return '根据综合分析,当前报告未检测到明显风险因素,各项指标表现正常,总体状况良好。'
}
else if (score >= 50 && score < 75) {
return '根据综合分析,当前报告存在少量风险信号,建议关注相关指标变化,保持警惕。'
}
else if (score >= 25 && score < 50) {
return '根据综合分析,当前报告风险指数较高,多项指标显示异常,建议进一步核实相关情况。'
}
else {
return '根据综合分析,当前报告显示高度风险状态,多项重要指标严重异常,请立即采取相应措施。'
}
})
const chartRef = ref(null)
let chartInstance = null
function initChart() {
if (!chartRef.value)
return
// 初始化ECharts实例
chartInstance = echarts.init(chartRef.value)
updateChart()
}
function updateChart() {
if (!chartInstance)
return
// 获取当前风险等级信息
const risk = riskLevel.value
// 配置项
const option = {
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
min: 0,
max: 100,
radius: '100%',
center: ['50%', '80%'],
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, risk.gradient),
shadowBlur: 6,
shadowColor: risk.color,
},
progress: {
show: true,
width: 20,
roundCap: true,
clip: false,
},
axisLine: {
roundCap: true,
lineStyle: {
width: 20,
color: [
[1, new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: `${risk.color}30`, // 使用风险颜色透明度20%
},
{
offset: 1,
color: `${risk.color}25`, // 使用风险颜色透明度10%
},
])],
],
},
},
axisTick: {
show: true,
distance: -30,
length: 6,
splitNumber: 10, // 每1分一个小刻度
lineStyle: {
color: risk.color,
width: 1,
opacity: 0.5,
},
},
splitLine: {
show: true,
distance: -36,
length: 12,
splitNumber: 9, // 9个大刻度100分分成9个区间
lineStyle: {
color: risk.color,
width: 2,
opacity: 0.5,
},
},
axisLabel: {
show: false,
},
anchor: {
show: false,
},
pointer: {
icon: 'triangle',
iconStyle: {
color: risk.color,
borderColor: risk.color,
borderWidth: 1,
},
offsetCenter: ['7%', '-67%'],
length: '10%',
width: 15,
},
detail: {
valueAnimation: true,
fontSize: 30,
fontWeight: 'bold',
color: risk.color,
offsetCenter: [0, '-25%'],
formatter(value) {
return `{value|${value}分}\n{level|${risk.level}}`
},
rich: {
value: {
fontSize: 30,
fontWeight: 'bold',
color: risk.color,
padding: [0, 0, 5, 0],
},
level: {
fontSize: 14,
fontWeight: 'normal',
color: risk.color,
padding: [5, 0, 0, 0],
},
},
},
data: [
{
value: props.score,
},
],
title: {
fontSize: 14,
color: risk.color,
offsetCenter: [0, '10%'],
formatter: risk.level,
},
},
],
}
// 使用配置项设置图表
chartInstance.setOption(option)
}
// 监听分数变化
watch(
() => props.score,
() => {
updateChart()
},
)
onMounted(() => {
initChart()
// 处理窗口大小变化
window.addEventListener('resize', () => {
if (chartInstance) {
chartInstance.resize()
}
})
})
// 在组件销毁前清理
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', chartInstance?.resize)
})
</script>
<template>
<view>
<view class="risk-description">
{{ riskDescription }}
</view>
<view ref="chartRef" :style="{ width: '100%', height: '200px' }" />
</view>
</template>
<style scoped>
.risk-description {
margin-bottom: 4px;
padding: 0 12px;
color: #666666;
font-size: 12px;
line-height: 1.5;
text-align: center;
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
imageUrl: {
type: String,
default: '',
},
title: {
type: String,
default: '保存图片到相册',
},
})
const emit = defineEmits(['close'])
function close() {
emit('close')
}
// 自动关闭功能已禁用
// watch(() => props.show, (newVal) => {
// if (newVal && props.autoCloseDelay > 0) {
// setTimeout(() => {
// close();
// }, props.autoCloseDelay);
// }
// });
</script>
<template>
<view v-if="show" class="image-save-guide-overlay">
<view class="guide-content" @click.stop>
<!-- 关闭按钮 -->
<button class="close-button" @click="close">
<text class="close-icon">×</text>
</button>
<!-- 图片区域 -->
<view v-if="imageUrl" class="image-container">
<image :src="imageUrl" class="guide-image" />
</view>
<!-- 文字内容区域 -->
<view class="text-container">
<view class="guide-title">
{{ title }}
</view>
<view class="guide-instruction">
长按图片保存
</view>
</view>
</view>
</view>
</template>
<style scoped>
.image-save-guide-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
padding: 16px;
box-sizing: border-box;
}
.guide-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 400px;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
color: #333;
text-align: center;
position: relative;
}
/* 关闭按钮 */
.close-button {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border: none;
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
z-index: 10;
}
.close-button:hover {
background: rgba(0, 0, 0, 0.2);
}
.close-icon {
font-size: 20px;
color: #666;
font-weight: bold;
line-height: 1;
}
/* 图片容器 */
.image-container {
width: 100%;
margin-bottom: 20px;
display: flex;
justify-content: center;
}
.guide-image {
max-width: 100%;
max-height: 60vh;
width: auto;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
object-fit: contain;
}
/* 文字容器 */
.text-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.guide-title {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.3;
margin-bottom: 4px;
}
.guide-instruction {
font-size: 16px;
color: #4a4a4a;
line-height: 1.4;
font-weight: 500;
}
/* 超小屏幕 (320px - 375px) */
@media (max-width: 375px) {
.image-save-guide-overlay {
padding: 12px;
}
.guide-content {
padding: 20px 16px;
max-width: 100%;
}
.guide-title {
font-size: 18px;
}
.guide-instruction {
font-size: 15px;
}
.close-button {
width: 28px;
height: 28px;
top: 10px;
right: 10px;
}
.close-icon {
font-size: 18px;
}
.guide-image {
max-height: 50vh;
}
}
/* 小屏幕 (376px - 414px) */
@media (min-width: 376px) and (max-width: 414px) {
.guide-content {
padding: 22px 18px;
}
.guide-title {
font-size: 19px;
}
}
/* 中等屏幕 (415px - 480px) */
@media (min-width: 415px) and (max-width: 480px) {
.guide-content {
padding: 24px 20px;
}
.guide-title {
font-size: 20px;
}
}
/* 大屏幕 (481px+) */
@media (min-width: 481px) {
.guide-content {
padding: 28px 24px;
max-width: 420px;
}
.guide-title {
font-size: 22px;
}
.guide-instruction {
font-size: 17px;
}
}
/* 横屏适配 */
@media (orientation: landscape) and (max-height: 500px) {
.guide-content {
padding: 16px 20px;
}
.image-container {
margin-bottom: 12px;
}
.guide-image {
max-height: 40vh;
}
.text-container {
gap: 8px;
}
.guide-title {
font-size: 18px;
margin-bottom: 2px;
}
.guide-instruction {
font-size: 15px;
}
}
/* 深色模式适配 */
@media (prefers-color-scheme: dark) {
.guide-content {
background: rgba(30, 30, 30, 0.95);
color: #e5e5e5;
}
.guide-title {
color: #ffffff;
}
.guide-instruction {
color: #d1d5db;
}
}
</style>

View File

@@ -0,0 +1,842 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import BindPhoneDialog from '@/components/BindPhoneDialog.vue'
import LoginDialog from '@/components/LoginDialog.vue'
import Payment from '@/components/Payment.vue'
import SectionTitle from '@/components/SectionTitle.vue'
import { useRouter } from '@/composables/uni-router'
import { useEnv } from '@/composables/useEnv'
import { useDialogStore } from '@/stores/dialogStore'
import { useUserStore } from '@/stores/userStore'
import { aesEncrypt } from '@/utils/crypto'
import { setAuthSession } from '@/utils/storage'
// Props
const props = defineProps({
// 查询类型:'normal' | 'promotion'
type: {
type: String,
default: 'normal',
},
// 产品特征
feature: {
type: String,
required: true,
},
// 推广链接标识符(仅推广查询需要)
linkIdentifier: {
type: String,
default: '',
},
// 产品数据(从外部传入)
featureData: {
type: Object,
default: () => ({}),
},
})
// Emits
const emit = defineEmits(['submit-success'])
const PRODUCT_BACKGROUND_MAP = {
companyinfo: '/static/images/product/xwqy_inquire_bg.png',
preloanbackgroundcheck: '/static/images/product/dqfx_inquire_bg.png',
personalData: '/static/images/product/grdsj_inquire_bg.png',
marriage: '/static/images/product/marriage_inquire_bg.png',
homeservice: '/static/images/product/homeservice_inquire_bg.png',
backgroundcheck: '/static/images/product/backgroundcheck_inquire_bg.png',
rentalinfo: '/static/images/product/rentalinfo_inquire_bg.png',
}
const TRAPEZOID_BACKGROUND_MAP = {
marriage: '/static/images/report/title_inquire_bg_red.png',
homeservice: '/static/images/report/title_inquire_bg_green.png',
default: '/static/images/report/title_inquire_bg.png',
}
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
const { feature } = toRefs(props)
function loadProductBackground(productType) {
return PRODUCT_BACKGROUND_MAP[productType] || ''
}
const router = useRouter()
const dialogStore = useDialogStore()
const userStore = useUserStore()
const { isWeChat } = useEnv()
// 响应式数据
const showPayment = ref(false)
const pendingPayment = ref(false)
const queryId = ref(null)
const productBackground = ref('')
const productMainImage = ref('')
const trapezoidBgImage = ref('')
const isCountingDown = ref(false)
const countdown = ref(60)
const verificationCodeInputRef = ref(null)
// 使用传入的featureData或创建响应式引用
const featureData = computed(() => props.featureData || {})
// 表单数据
const formData = reactive({
name: '',
idCard: '',
mobile: '',
verificationCode: '',
agreeToTerms: false,
})
// 计算属性
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(formData.mobile)
})
const isIdCardValid = computed(() => /^\d{17}[\dX]$/i.test(formData.idCard))
// 小微企业(companyinfo)暂不需要验证码
const needVerificationCode = computed(() => props.feature !== 'companyinfo')
const isLoggedIn = computed(() => userStore.isLoggedIn)
const buttonText = computed(() => {
return isLoggedIn.value ? '立即查询' : '前往登录'
})
const hasHeroImage = computed(() => Boolean(productBackground.value))
function loadTrapezoidBackground() {
trapezoidBgImage.value = TRAPEZOID_BACKGROUND_MAP[props.feature] || TRAPEZOID_BACKGROUND_MAP.default
}
// 牌匾背景图片样式
const trapezoidBgStyle = computed(() => {
if (trapezoidBgImage.value) {
return {
backgroundImage: `url(${trapezoidBgImage.value})`,
}
}
return {}
})
// 牌匾文字样式
const trapezoidTextStyle = computed(() => {
// homeservice 和 marriage 使用白色文字
if (props.feature === 'homeservice' || props.feature === 'marriage') {
return {
color: 'white',
}
}
// 其他情况使用默认字体色(不设置 color使用浏览器默认或继承
return {}
})
// 获取功能图标
function getFeatureIcon(apiId) {
const iconMap = {
JRZQ4AA8: '/static/inquire_icons/huankuanyali.svg', // 还款压力
QCXG7A2B: '/static/inquire_icons/mingxiacheliang.svg', // 名下车辆
BehaviorRiskScan: '/static/inquire_icons/fengxianxingwei.svg', // 风险行为扫描
IVYZ5733: '/static/inquire_icons/hunyinzhuangtai.svg', // 婚姻状态
PersonEnterprisePro: '/static/inquire_icons/renqiguanxi.svg', // 人企关系加强版
JRZQ0A03: '/static/inquire_icons/jiedaishenqing.svg', // 借贷申请记录
FLXG3D56: '/static/inquire_icons/jiedaiweiyue.svg', // 借贷违约失信
FLXG0V4B: '/static/inquire_icons/sifasheyu.svg', // 司法涉诉
JRZQ8203: '/static/inquire_icons/jiedaixingwei.svg', // 借贷行为记录
JRZQ09J8: '/static/inquire_icons/beijianguanrenyuan.svg', // 收入评估
JRZQ4B6C: '/static/inquire_icons/fengxianxingwei.svg', // 探针C风险评估
}
return iconMap[apiId] || '/static/inquire_icons/default.svg'
}
// 处理图标加载错误
function handleIconError(event) {
event.target.style.display = 'none'
}
// 获取卡片样式类
function getCardClass(index) {
const colorIndex = index % 4
const colorClasses = [
'bg-gradient-to-br from-blue-50 via-blue-25 to-white border-2 border-blue-200',
'bg-gradient-to-br from-green-50 via-green-25 to-white border-2 border-green-200',
'bg-gradient-to-br from-purple-50 via-purple-25 to-white border-2 border-purple-200',
'bg-gradient-to-br from-orange-50 via-orange-25 to-white border-2 border-orange-200',
]
return colorClasses[colorIndex]
}
// 方法
function validateField(field, value, validationFn, errorMessage) {
if (isHasInput(field) && !validationFn(value)) {
showToast({ message: errorMessage })
return false
}
return true
}
const defaultInput = ['name', 'idCard', 'mobile', 'verificationCode']
function isHasInput(input) {
return defaultInput.includes(input)
}
// 处理绑定手机号成功的回调
function handleBindSuccess() {
if (pendingPayment.value) {
pendingPayment.value = false
submitRequest()
}
}
// 处理登录成功的回调
function handleLoginSuccess() {
if (pendingPayment.value) {
pendingPayment.value = false
submitRequest()
}
}
// 处理输入框点击事件
async function handleInputClick() {
if (!isLoggedIn.value) {
// 非微信浏览器环境:未登录用户提示打开登录弹窗
if (!isWeChat.value) {
const { confirm } = await uni.showModal({
title: '提示',
content: '您需要登录后才能进行查询,是否立即登录?',
confirmText: '立即登录',
cancelText: '取消',
})
if (confirm)
dialogStore.openLogin()
}
}
else {
// 微信浏览器环境:已登录但检查是否需要绑定手机号
if (isWeChat.value && !userStore.mobile) {
dialogStore.openBindPhone()
}
}
}
function handleSubmit() {
// 非微信浏览器环境:检查登录状态
if (!isWeChat.value && !isLoggedIn.value) {
dialogStore.openLogin()
return
}
// 基本协议验证
if (!formData.agreeToTerms) {
showToast({ message: `请阅读并同意用户协议和隐私政策` })
return
}
if (
!validateField('name', formData.name, value => value, '请输入姓名')
|| !validateField(
'mobile',
formData.mobile,
() => isPhoneNumberValid.value,
'请输入有效的手机号',
)
|| !validateField(
'idCard',
formData.idCard,
() => isIdCardValid.value,
'请输入有效的身份证号码',
)
|| (needVerificationCode.value
&& !validateField(
'verificationCode',
formData.verificationCode,
value => value,
'请输入验证码',
))
) {
return
}
// 检查是否需要绑定手机号
if (!userStore.mobile) {
pendingPayment.value = true
dialogStore.openBindPhone()
}
else {
submitRequest()
}
}
async function submitRequest() {
const req = {
name: formData.name,
id_card: formData.idCard,
mobile: formData.mobile,
}
if (needVerificationCode.value) {
req.code = formData.verificationCode
}
const reqStr = JSON.stringify(req)
const inquireKey = import.meta.env.VITE_INQUIRE_AES_KEY
if (!inquireKey) {
throw new Error('缺少环境变量: VITE_INQUIRE_AES_KEY')
}
const encodeData = aesEncrypt(reqStr, inquireKey)
let apiUrl = ''
const requestData = { data: encodeData }
if (props.type === 'promotion') {
apiUrl = `/query/service_agent/${props.feature}`
requestData.agent_identifier = props.linkIdentifier
}
else {
apiUrl = `/query/service/${props.feature}`
}
const { data } = await useApiFetch(apiUrl)
.post(requestData)
.json()
if (data.value.code === 200) {
queryId.value = data.value.data.id
// 推广查询需要保存token
if (props.type === 'promotion') {
setAuthSession(data.value.data)
}
showPayment.value = true
emit('submit-success', data.value.data)
}
}
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
const { data, error } = await useApiFetch('/auth/sendSms')
.post({ mobile: formData.mobile, actionType: 'query', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
showToast({ message: '验证码发送成功', type: 'success' })
startCountdown()
nextTick(() => {
verificationCodeInputRef.value?.focus?.()
})
}
else {
showToast({ message: data.value?.msg || '验证码发送失败,请重试' })
}
}
let timer = null
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
function toUserAgreement() {
router.push(`/userAgreement`)
}
function toPrivacyPolicy() {
router.push(`/privacyPolicy`)
}
function toAuthorization() {
router.push(`/authorization`)
}
function toExample() {
router.push(`/example?feature=${props.feature}`)
}
function toHistory() {
router.push('/historyQuery')
}
// 生命周期
onMounted(async () => {
loadBackgroundImage()
loadTrapezoidBackground()
})
// 加载背景图片
async function loadBackgroundImage() {
productBackground.value = loadProductBackground(props.feature)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
watch(feature, async () => {
loadBackgroundImage()
loadTrapezoidBackground()
})
</script>
<template>
<view class="inquire-bg relative min-h-screen">
<view v-if="hasHeroImage" class="hero-banner">
<image :src="productBackground" class="hero-banner-image" mode="widthFix" />
<view class="hero-banner-mask" />
<view class="hero-badge-wrap">
<view class="trapezoid-bg-image flex items-center justify-center" :style="trapezoidBgStyle">
<view class="whitespace-nowrap text-xl" :style="trapezoidTextStyle">
{{ featureData.product_name }}
</view>
</view>
</view>
</view>
<view class="content-wrap relative mx-4 min-h-screen pb-12" :class="{ 'with-hero': hasHeroImage }">
<view class="card-container">
<!-- 基本信息标题 -->
<view class="mb-6 flex items-center">
<SectionTitle title="基本信息" />
<view class="ml-auto flex cursor-pointer items-center text-gray-600" @click="toExample">
<image src="/static/images/report/slbg_inquire_icon.png" alt="示例报告" class="mr-1 h-4 w-4" />
<text class="">
示例报告
</text>
</view>
</view>
<!-- 表单输入区域 -->
<view class="mb-6 space-y-4">
<view class="flex items-center border-b border-gray-100 py-3">
<text for="name" class="w-20 text-gray-700 font-medium">
姓名
</text>
<input id="name" v-model="formData.name" type="text" placeholder="请输入正确的姓名"
class="flex-1 border-none outline-none" @click="handleInputClick">
</view>
<view class="flex items-center border-b border-gray-100 py-3">
<text for="idCard" class="w-20 text-gray-700 font-medium">
身份证号
</text>
<input id="idCard" v-model="formData.idCard" type="text" placeholder="请输入准确的身份证号"
class="flex-1 border-none outline-none" @click="handleInputClick">
</view>
<view class="flex items-center border-b border-gray-100 py-3">
<text for="mobile" class="w-20 text-gray-700 font-medium">
手机号
</text>
<input id="mobile" v-model="formData.mobile" type="tel" placeholder="请输入手机号"
class="flex-1 border-none outline-none" @click="handleInputClick">
</view>
<!-- 小微企业(companyinfo)暂不展示验证码 -->
<view v-if="needVerificationCode" class="flex items-center border-b border-gray-100 py-3">
<text for="verificationCode" class="w-20 text-gray-700 font-medium">
验证码
</text>
<input id="verificationCode" ref="verificationCodeInputRef" v-model="formData.verificationCode"
placeholder="请输入验证码" maxlength="6" class="flex-1 border-none outline-none" @click="handleInputClick">
<wd-button class="captcha-wd-btn" size="small" type="primary" plain
:disabled="isCountingDown || !isPhoneNumberValid" @click="sendVerificationCode">
{{ isCountingDown ? `${countdown}s` : '获取验证码' }}
</wd-button>
</view>
</view>
<!-- 协议同意 -->
<view class="agreement-wrap mb-6">
<wd-checkbox v-model="formData.agreeToTerms" shape="square" size="18px" />
<view class="agreement-text">
<text class="text-sm text-gray-500">
我已阅读并同意
</text>
<text class="agreement-link" @click="toUserAgreement">
用户协议
</text>
<text class="agreement-link" @click="toPrivacyPolicy">
隐私政策
</text>
<text class="agreement-link" @click="toAuthorization">
授权书
</text>
</view>
</view>
<!-- 查询按钮 -->
<button
class="bg-primary mb-4 mt-10 w-full flex items-center justify-center rounded-[48px] py-4 text-lg text-white font-medium"
@click="handleSubmit">
<text>{{ buttonText }}</text>
<text class="ml-4">
¥{{ featureData.sell_price }}
</text>
</button>
<!-- <view class="text-xs text-gray-500 leading-relaxed mt-8" v-html="featureData.description">
</view> -->
<!-- 免责声明 -->
<view class="mt-2 text-center text-xs text-gray-500 leading-relaxed">
为保证用户的隐私及数据安全查询结果生成30天后将自动删除
</view>
</view>
<!-- 报告包含内容 -->
<view v-if="featureData.features && featureData.features.length > 0" class="card mt-3">
<view class="mb-3 flex items-center text-base font-semibold" style="color: var(--van-text-color);">
<view class="mr-2 h-5 w-1 rounded-full"
style="background: linear-gradient(to bottom, var(--van-theme-primary), var(--van-theme-primary-dark));" />
报告包含内容
</view>
<view class="grid grid-cols-4 items-stretch gap-2">
<template v-for="(item, index) in featureData.features" :key="item.id">
<!-- FLXG0V4B 特殊处理显示8个独立的案件类型 -->
<template v-if="item.api_id === 'FLXG0V4B'">
<view v-for="(caseType, caseIndex) in [
{ name: '管辖案件', icon: 'beijianguanrenyuan.svg' },
{ name: '刑事案件', icon: 'xingshi.svg' },
{ name: '民事案件', icon: 'minshianjianguanli.svg' },
{ name: '失信被执行', icon: 'shixinren.svg' },
{ name: '行政案件', icon: 'xingzhengfuwu.svg' },
{ name: '赔偿案件', icon: 'yuepeichang.svg' },
{ name: '执行案件', icon: 'zhixinganjian.svg' },
{ name: '限高被执行', icon: 'xianzhigaoxiaofei.svg' },
]" :key="`${item.id}-${caseIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + caseIndex)">
<view class="mb-1 shrink-0">
<image :src="`/static/inquire_icons/${caseType.icon}`" :alt="caseType.name"
class="mx-auto h-6 w-6 drop-shadow-sm" @error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-0.5 text-xs font-medium leading-snug">
{{ caseType.name }}
</view>
</view>
</template>
<!-- DWBG8B4D 特殊处理:显示拆分模块 -->
<template v-else-if="item.api_id === 'DWBG8B4D'">
<view v-for="(module, moduleIndex) in [
{ name: '要素核查', icon: 'beijianguanrenyuan.svg' },
{ name: '运营商核验', icon: 'mingxiacheliang.svg' },
{ name: '公安重点人员检验', icon: 'xingshi.svg' },
{ name: '逾期风险综述', icon: 'huankuanyali.svg' },
{ name: '法院曝光台信息', icon: 'sifasheyu.svg' },
{ name: '借贷评估', icon: 'jiedaishenqing.svg' },
{ name: '租赁风险评估', icon: 'jiedaixingwei.svg' },
{ name: '关联风险监督', icon: 'renqiguanxi.svg' },
{ name: '规则风险提示', icon: 'fengxianxingwei.svg' },
]" :key="`${item.id}-${moduleIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + moduleIndex)">
<view class="mb-1 flex shrink-0 items-center justify-center text-xl">
<image :src="`/static/inquire_icons/${module.icon}`" :alt="module.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ module.name }}
</view>
</view>
</template>
<!-- CJRZQ5E9F 特殊处理:显示拆分模块 -->
<template v-else-if="item.api_id === 'JRZQ5E9F'">
<view v-for="(module, moduleIndex) in [
{ name: '信用评分', icon: 'huankuanyali.svg' },
{ name: '贷款行为分析', icon: 'jiedaixingwei.svg' },
{ name: '机构分析', icon: 'jiedaishenqing.svg' },
{ name: '时间趋势分析', icon: 'zhixinganjian.svg' },
{ name: '风险指标详情', icon: 'fengxianxingwei.svg' },
{ name: '专业建议', icon: 'yuepeichang.svg' },
]" :key="`${item.id}-${moduleIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + moduleIndex)">
<view class="mb-1 flex shrink-0 items-center justify-center text-xl">
<image :src="`/static/inquire_icons/${module.icon}`" :alt="module.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ module.name }}
</view>
</view>
</template>
<!-- PersonEnterprisePro/CQYGL3F8E 特殊处理:显示拆分模块 -->
<template v-else-if="item.api_id === 'PersonEnterprisePro' || item.api_id === 'QYGL3F8E'">
<view v-for="(module, moduleIndex) in [
{ name: '投资企业记录', icon: 'renqiguanxi.svg' },
{ name: '高管任职记录', icon: 'beijianguanrenyuan.svg' },
{ name: '涉诉风险', icon: 'sifasheyu.svg' },
{ name: '对外投资历史', icon: 'renqiguanxi.svg' },
{ name: '融资历史', icon: 'huankuanyali.svg' },
{ name: '行政处罚', icon: 'xingzhengfuwu.svg' },
{ name: '经营异常', icon: 'fengxianxingwei.svg' },
]" :key="`${item.id}-${moduleIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + moduleIndex)">
<view class="mb-1 flex shrink-0 items-center justify-center text-xl">
<image :src="`/static/inquire_icons/${module.icon}`" :alt="module.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ module.name }}
</view>
</view>
</template>
<!-- DWBG6A2C 特殊处理:显示拆分模块 -->
<template v-else-if="item.api_id === 'DWBG6A2C'">
<view v-for="(module, moduleIndex) in [
{ name: '命中风险标注', icon: 'fengxianxingwei.svg' },
{ name: '公安重点人员核验', icon: 'beijianguanrenyuan.svg' },
{ name: '涉赌涉诈人员核验', icon: 'xingshi.svg' },
{ name: '风险名单', icon: 'jiedaiweiyue.svg' },
{ name: '历史借贷行为', icon: 'jiedaixingwei.svg' },
{ name: '近24个月放款情况', icon: 'jiedaishenqing.svg' },
{ name: '履约情况', icon: 'huankuanyali.svg' },
{ name: '历史逾期记录', icon: 'jiedaiweiyue.svg' },
{ name: '授信详情', icon: 'huankuanyali.svg' },
{ name: '租赁行为', icon: 'mingxiacheliang.svg' },
{ name: '关联风险监督', icon: 'renqiguanxi.svg' },
{ name: '法院风险信息', icon: 'sifasheyu.svg' },
]" :key="`${item.id}-${moduleIndex}`"
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index + moduleIndex)">
<view class="mb-1 flex shrink-0 items-center justify-center text-xl">
<image :src="`/static/inquire_icons/${module.icon}`" :alt="module.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ module.name }}
</view>
</view>
</template>
<!-- 其他功能正常显示 -->
<view v-else
class="h-full min-h-0 w-full flex flex-col items-center rounded-xl p-2 text-center text-sm text-gray-700 font-medium shadow-lg"
:class="getCardClass(index)">
<view class="mb-1 flex shrink-0 items-center justify-center">
<image :src="getFeatureIcon(item.api_id)" :alt="item.name" class="h-6 w-6 drop-shadow-sm"
@error="handleIconError" />
</view>
<view
class="w-full flex flex-1 items-center justify-center break-all px-1 text-xs font-medium leading-snug">
{{ item.name }}
</view>
</view>
</template>
</view>
<view class="mt-3 text-center">
<view class="inline-flex items-center border rounded-full px-3 py-1.5 transition-all"
style="background: linear-gradient(135deg, var(--van-theme-primary-light), rgba(255,255,255,0.8)); border-color: var(--van-theme-primary);">
<view class="mr-1.5 h-1.5 w-1.5 rounded-full" style="background-color: var(--van-theme-primary);" />
<text class="text-xs font-medium" style="color: var(--van-theme-primary);">
更多信息请解锁报告
</text>
</view>
</view>
</view>
<!-- 产品详情卡片 -->
<view class="card mt-4">
<view class="mb-4 text-xl font-bold" style="color: var(--van-text-color);">
{{ featureData.product_name }}
</view>
<view class="mb-4 flex items-start justify-between">
<view class="text-lg" style="color: var(--van-text-color-2);">
价格:
</view>
<view>
<view class="text-danger text-2xl font-semibold">
¥{{ featureData.sell_price }}
</view>
</view>
</view>
<image v-if="productMainImage" :src="productMainImage" alt="产品详情主图" class="mb-4 w-full rounded-lg" />
<view class="mb-4 leading-relaxed" style="color: var(--van-text-color-2);" v-html="featureData.description" />
<view class="text-danger mb-2 text-xs italic">
为保证用户的隐私以及数据安全查询的结果生成30天之后将自动清除。
</view>
</view>
</view>
<!-- 支付组件 -->
<Payment :id="queryId" v-model="showPayment" :data="featureData" type="query" @close="showPayment = false" />
<BindPhoneDialog @bind-success="handleBindSuccess" />
<LoginDialog @login-success="handleLoginSuccess" />
<!-- 历史查询按钮 - 仅推广查询且已登录时显示 -->
<view v-if="props.type === 'promotion' && isLoggedIn"
class="bg-primary fixed right-2 top-3/4 cursor-pointer rounded-xl px-4 py-2 text-sm text-white font-bold shadow active:bg-blue-500"
@click="toHistory">
历史查询
</view>
</view>
</template>
<style scoped>
/* 背景样式 */
.inquire-bg {
background-color: var(--color-primary-50);
min-height: 100vh;
position: relative;
}
.hero-banner {
position: relative;
overflow: hidden;
border-bottom-left-radius: 24px;
border-bottom-right-radius: 24px;
background: linear-gradient(180deg, #eef4ff 0%, #dfe9ff 100%);
}
.hero-banner-image {
display: block;
width: 100%;
margin-top: -2px;
}
.hero-banner-mask {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(20, 32, 64, 0.04) 0%, rgba(20, 32, 64, 0.1) 100%);
pointer-events: none;
}
.hero-badge-wrap {
position: absolute;
left: 50%;
bottom: 34px;
transform: translateX(-50%);
width: 160px;
display: flex;
justify-content: center;
z-index: 3;
}
.content-wrap.with-hero {
margin-top: -68px;
padding-top: 0;
z-index: 2;
}
/* 卡片样式优化 */
.card {
@apply shadow-lg rounded-xl p-6 transition-all hover:shadow-xl;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
/* 按钮悬停效果 */
button:hover {
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
/* 梯形背景图片样式 */
.trapezoid-bg-image {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
height: 44px;
width: 100%;
}
/* 卡片容器样式 */
.card-container {
background: white;
border-radius: 20px;
padding: 32px 16px;
box-shadow: 0px 0px 24px 0px #3F3F3F0F;
border: 1px solid rgba(255, 255, 255, 0.75);
}
.card-container input::placeholder {
color: #DDDDDD;
}
.captcha-wd-btn {
margin-left: 12px;
}
:deep(.captcha-wd-btn.wd-button) {
min-width: 96px;
}
:deep(.captcha-wd-btn.wd-button.is-small) {
padding: 0 10px;
}
.agreement-wrap {
display: flex;
align-items: flex-start;
gap: 10px;
}
:deep(.agreement-wrap .wd-checkbox) {
margin-top: 1px;
}
.agreement-text {
flex: 1;
font-size: 13px;
line-height: 1.7;
word-break: break-all;
}
.agreement-link {
color: #2563eb;
}
/* 功能标签样式 */
.feature-tag {
background-color: var(--color-primary-light);
color: var(--color-primary);
padding: 6px 12px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
}
/* 功能标签圆点 */
.feature-dot {
width: 6px;
height: 6px;
background-color: var(--color-primary);
border-radius: 50%;
margin-right: 8px;
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup>
// 接收 type 和 options props 以及 v-model
const props = defineProps({
type: {
type: String,
default: 'purple-pink', // 默认颜色渐变
},
options: {
type: Array,
required: true, // 动态传入选项
},
modelValue: {
type: String,
default: '', // v-model 绑定的值
},
})
const emit = defineEmits(['update:modelValue'])
// 选中内容绑定 v-model
const selected = ref(props.modelValue)
// 监听 v-model 的变化
watch(() => props.modelValue, (newValue) => {
selected.value = newValue
})
// 根据type动态生成分割线的类名
const lineClass = computed(() => {
// 统一使用主题色渐变
return 'bg-gradient-to-r from-red-600 via-red-500 to-red-700'
})
// 计算滑动线的位置和宽度
const slideLineStyle = computed(() => {
const index = props.options.findIndex(option => option.value === selected.value)
const buttonWidth = 100 / props.options.length
return {
width: `${buttonWidth}%`,
transform: `translateX(${index * 100}%)`,
}
})
// 选择选项函数
function selectOption(option) {
selected.value = option.value
// 触发 v-model 的更新
emit('update:modelValue', option.value)
}
</script>
<template>
<view class="relative flex">
<view
v-for="(option, index) in options"
:key="index"
class="flex-1 shrink-0 cursor-pointer py-2 text-center text-size-sm font-bold transition-transform duration-200 ease-in-out"
:class="{ 'text-gray-900': selected === option.value, 'text-gray-500': selected !== option.value }"
@click="selectOption(option)"
>
{{ option.label }}
</view>
<view
class="absolute bottom-0 h-[3px] rounded transition-all duration-300"
:style="slideLineStyle"
:class="lineClass"
/>
</view>
</template>
<style scoped>
/* 自定义样式 */
button {
outline: none;
border: none;
cursor: pointer;
}
button:focus {
outline: none;
}
</style>

42
src/components/LEmpty.vue Normal file
View File

@@ -0,0 +1,42 @@
<script setup>
const route = useRoute()
// 返回上一页逻辑
function goBack() {
route.goBack()
}
</script>
<template>
<view class="card flex flex-col items-center justify-center text-center">
<!-- 图片插画 -->
<image src="/static/images/empty.svg" alt="空状态" class="h-64 w-64" />
<!-- 提示文字 -->
<text class="mb-2 text-xl text-gray-700 font-semibold">
没有查询到相关结果
</text>
<text class="mb-2 text-sm text-gray-500 leading-relaxed">
订单已申请退款预计
<text class="font-medium" style="color: var(--van-theme-primary);">24小时内到账</text>
</text>
<text class="text-xs text-gray-400">
如果已到账您可以忽略本提示
</text>
<!-- 返回按钮 -->
<button
class="mt-4 rounded-lg px-6 py-2 text-white transition duration-300 ease-in-out"
style="background-color: var(--van-theme-primary);"
onmouseover="this.style.backgroundColor='var(--van-theme-primary-dark)'"
onmouseout="this.style.backgroundColor='var(--van-theme-primary)'"
@click="goBack"
>
返回上一页
</button>
</view>
</template>
<style scoped>
/* 你可以添加一些额外的样式(如果需要) */
</style>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
content: {
type: String,
required: true,
},
})
const isExpanded = ref(false)
</script>
<template>
<view class="l-remark-card my-[20px]">
<!-- 顶部连接点 -->
<view class="connection-line">
<image
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAACTCAYAAADmz7tVAAAAAXNSR0IArs4c6QAAB59JREFUeF7tXFtsFFUY/s7sbreFUiyFglxaEJUgVaAYNBAjl/hgYrgU44PhRd+Ud3xQE4xP8izx0RfiGwX1xcQYMApegFakSghgWygIFVqope3e5phvzo47O53ZmcM2zT6cSTZsujPnfPP9l/P/w3xHQOM42S/rFxTQhCQ6bAtdkNgIidUSaOYwAhiFwDUI9Fo2upFH30gCY9tXiam404i4J/42KPcXJLosYOucRrTmckChANgFQEo1ihCAlQASCSCVAibGMWwDpxMC3RvaxdE4c0UC6rkhX4aNzyDxlGUhycldAFETECA/to08BK7AwjudK8T3la4LBXTmhmxosHEomcTBfD4+iLDJCCyZBPJ5HJ60cGjLCjEZdG4goL5+uSQrcCSdRlcmE8WD3u/pNJDJoLtO4kDHKnHbf/U0QASTEzglLKyx7fDJHH8pXs3vPFxT2hFmtSxA2ricktjmB1UGiGaqL+CoZaErDAz9N5cH7o0DQyPAv1PARFYBmlMHzKsHli8AWhqBVNKJvMCDoGwb3VMJ7Pear+z83kH5SV0dDgaZKWEBow+B6/eAm6PAVE4xxAG8DBEwGapPAcuagbYWoHkuUAhgm+bLZnF4Y7t4z0X9PyBGU8rCKYbzNLsK4NYI0HcTyOTD79p/HcGlk0DHMmDpguDAYHrI2djmRl8J0KD8UwBr/SHNu6dpegZKPqPnxoqxzpXKlEHjS+BSZ7t4pphcASY9W+JzAEnvZDTT0D3gXBVg3PEI6nmCagk0X94SeIvJU3A5aBL4ImFhr9+RxyaBX64B2bwuJ8Hn1yWBF1YDTQ3lv9PBCzaOj0m8KS5cla12Ehch0Oqlk/b/YwgYuBvfZ6Jgc8yVC4F1y8vHdIJCYtjK41lxYUDuqG/Edw/Hy4cjKycvAflC1DR6vycTwPa1ANnyHnMbgalx7BS91+WnqRQOZD0ZmYhvjQK//gUkrfAJyWjeLjkqqU8U16+wq3j+5ieApc3lDl6XBnI5HBG9g/K0lcCWgsdP6Mw/XwVuPwiOLALhHa5cBHS2Ay3zFJM3R4Dfh4C/R51QDkyKdO4l84EXnyx37kTSqRzOiN4BeRsCi73+Q0Df9gETmVLSc++Y5zHRvboeWN0KZHJArgBkCyqDE0j/MHCuHxjn9T6qeP2cNPBKRzmgoh/dET0Dksaq815HO584H3yHqQTw+mblnE5WtpXZCIZJcyoLTOWVyX+6Gpyhed2eTYH+mQ0F9OX56V7Au1uzFHhjs0p2PPgvzUWGyBaXFILiUvHjFWU+d2nxjrg7DJCOyWiat18C2haWhnYB8TeHoRwwmVW5iz74w2WALuA1eUWT6Tg1J/lgj4o8965pMrLhZ4hmI2Nf9QB0AW/GruzUGmFPBj7eBxSkCm8e/M7amoCyRZORITJF9o6dLQcUGfY6ibEioLwC5ZgsEw4oMjHqLB3VAoq1dOgsrtUCirW40g/ilh/VAIpdfrje3xOjQGNohzp1BR/iGqdVoBFUnBL2/CDw4e6QKAsB9HUvsKHtEUpYgooq8v8ZA7atVU38tLAPAMSouz+hOhDtIp+Aotog+sH6NpUU4wByGoKQPihWG0RQlRpFZuX17dUDit0oug4e1krPBCDtVtoFFfSwoRpAVT1s8JYJ3scxEkg+t0LPZHxWNCOPY/zVEJNnroA9m1ZhH507jlOzPygUcMwSODFjD6z8wKSUMnC1Dwj7VAOwZmFYnAW3AZFP0AygsP4p7O/GZFGMGYYMQz4GTB6KcgnDkGHI20qb1b7s6Yeph4rhYRbXqDxhGDIMmfLDNIrug3OzdJilIyojGoYMQ+YJGmC6DtN1BMWBqamj8qNhyDBkug7TdZiuwxcFJjGaxGgSo0mMJjGaxBiVCQ1DhqHKDJj/Jo/yEMOQYci8SGC6jqgoMAyFMVQzbwvXzPvUNfXGeU29k19TqoWa03XUlPKlam1QiALvkbVBWuopG6B6hUeoRpHbcEig+6w6V0s9paUv61ITufs1hKk4HY2iDXSfUxrF2PoyXQXeR/vURH5AfhUnhUpUTB0vAiKbsRR4uhrFQ3vVRK52ld+nKYGp5swr4FQUu7LSWBpFHRUnda7v71L0e33IUQJT6+oqgXNKeMvzqCj26lwjVZw68nZOcvA1Z8+Oksm47QaF226U8bWd4odSZr/wNlLnqqME5qTv7lR37FcCO4CKAm5HVV4E+c3Fcs3+jMrbeXe7NgKPP1ZSmjtRRj9yWSqCoW+NPFS6fS2ttI68nSbgxiI71pWk6fwb0wABOZ8iODLIrTnuPAjQ7M+UvJ2hS+H21qeB1ia1xYELyNXdkxmyxq1cLlwPFktynBmRt3Mghu7ctJKKLm5SjLggCI7MDI8Bl26pPWW0dyTQcWq3vCUoMrWoCWhvAeY3KKbuTwKDdxU7BBr04CDaqTXk7f56myCcrX2KPxCAs8FNhUcYkWGvkxijWqQ4v0cmRp2lI86Elc6JtXToLK7VAoq1uHKSuOVHNYBilx/uJHEKNLfk0AWmvf8QJ4hTws7qDk0EFVXkz+oeVgQU1Qa5mXrWdvnihDW1D5rrrDW1U5wLqqb20vOGdc3sNujPNTWzH6MX2GzsWPkfBLU1i3+dVUIAAAAASUVORK5CYII="
alt="左链条" class="connection-chain left" />
<image
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAACTCAYAAADmz7tVAAAAAXNSR0IArs4c6QAAB59JREFUeF7tXFtsFFUY/s7sbreFUiyFglxaEJUgVaAYNBAjl/hgYrgU44PhRd+Ud3xQE4xP8izx0RfiGwX1xcQYMApegFakSghgWygIFVqope3e5phvzo47O53ZmcM2zT6cSTZsujPnfPP9l/P/w3xHQOM42S/rFxTQhCQ6bAtdkNgIidUSaOYwAhiFwDUI9Fo2upFH30gCY9tXiam404i4J/42KPcXJLosYOucRrTmckChANgFQEo1ihCAlQASCSCVAibGMWwDpxMC3RvaxdE4c0UC6rkhX4aNzyDxlGUhycldAFETECA/to08BK7AwjudK8T3la4LBXTmhmxosHEomcTBfD4+iLDJCCyZBPJ5HJ60cGjLCjEZdG4goL5+uSQrcCSdRlcmE8WD3u/pNJDJoLtO4kDHKnHbf/U0QASTEzglLKyx7fDJHH8pXs3vPFxT2hFmtSxA2ricktjmB1UGiGaqL+CoZaErDAz9N5cH7o0DQyPAv1PARFYBmlMHzKsHli8AWhqBVNKJvMCDoGwb3VMJ7Pear+z83kH5SV0dDgaZKWEBow+B6/eAm6PAVE4xxAG8DBEwGapPAcuagbYWoHkuUAhgm+bLZnF4Y7t4z0X9PyBGU8rCKYbzNLsK4NYI0HcTyOTD79p/HcGlk0DHMmDpguDAYHrI2djmRl8J0KD8UwBr/SHNu6dpegZKPqPnxoqxzpXKlEHjS+BSZ7t4pphcASY9W+JzAEnvZDTT0D3gXBVg3PEI6nmCagk0X94SeIvJU3A5aBL4ImFhr9+RxyaBX64B2bwuJ8Hn1yWBF1YDTQ3lv9PBCzaOj0m8KS5cla12Ehch0Oqlk/b/YwgYuBvfZ6Jgc8yVC4F1y8vHdIJCYtjK41lxYUDuqG/Edw/Hy4cjKycvAflC1DR6vycTwPa1ANnyHnMbgalx7BS91+WnqRQOZD0ZmYhvjQK//gUkrfAJyWjeLjkqqU8U16+wq3j+5ieApc3lDl6XBnI5HBG9g/K0lcCWgsdP6Mw/XwVuPwiOLALhHa5cBHS2Ay3zFJM3R4Dfh4C/R51QDkyKdO4l84EXnyx37kTSqRzOiN4BeRsCi73+Q0Df9gETmVLSc++Y5zHRvboeWN0KZHJArgBkCyqDE0j/MHCuHxjn9T6qeP2cNPBKRzmgoh/dET0Dksaq815HO584H3yHqQTw+mblnE5WtpXZCIZJcyoLTOWVyX+6Gpyhed2eTYH+mQ0F9OX56V7Au1uzFHhjs0p2PPgvzUWGyBaXFILiUvHjFWU+d2nxjrg7DJCOyWiat18C2haWhnYB8TeHoRwwmVW5iz74w2WALuA1eUWT6Tg1J/lgj4o8965pMrLhZ4hmI2Nf9QB0AW/GruzUGmFPBj7eBxSkCm8e/M7amoCyRZORITJF9o6dLQcUGfY6ibEioLwC5ZgsEw4oMjHqLB3VAoq1dOgsrtUCirW40g/ilh/VAIpdfrje3xOjQGNohzp1BR/iGqdVoBFUnBL2/CDw4e6QKAsB9HUvsKHtEUpYgooq8v8ZA7atVU38tLAPAMSouz+hOhDtIp+Aotog+sH6NpUU4wByGoKQPihWG0RQlRpFZuX17dUDit0oug4e1krPBCDtVtoFFfSwoRpAVT1s8JYJ3scxEkg+t0LPZHxWNCOPY/zVEJNnroA9m1ZhH507jlOzPygUcMwSODFjD6z8wKSUMnC1Dwj7VAOwZmFYnAW3AZFP0AygsP4p7O/GZFGMGYYMQz4GTB6KcgnDkGHI20qb1b7s6Yeph4rhYRbXqDxhGDIMmfLDNIrug3OzdJilIyojGoYMQ+YJGmC6DtN1BMWBqamj8qNhyDBkug7TdZiuwxcFJjGaxGgSo0mMJjGaxBiVCQ1DhqHKDJj/Jo/yEMOQYci8SGC6jqgoMAyFMVQzbwvXzPvUNfXGeU29k19TqoWa03XUlPKlam1QiALvkbVBWuopG6B6hUeoRpHbcEig+6w6V0s9paUv61ITufs1hKk4HY2iDXSfUxrF2PoyXQXeR/vURH5AfhUnhUpUTB0vAiKbsRR4uhrFQ3vVRK52ld+nKYGp5swr4FQUu7LSWBpFHRUnda7v71L0e33IUQJT6+oqgXNKeMvzqCj26lwjVZw68nZOcvA1Z8+Oksm47QaF226U8bWd4odSZr/wNlLnqqME5qTv7lR37FcCO4CKAm5HVV4E+c3Fcs3+jMrbeXe7NgKPP1ZSmjtRRj9yWSqCoW+NPFS6fS2ttI68nSbgxiI71pWk6fwb0wABOZ8iODLIrTnuPAjQ7M+UvJ2hS+H21qeB1ia1xYELyNXdkxmyxq1cLlwPFktynBmRt3Mghu7ctJKKLm5SjLggCI7MDI8Bl26pPWW0dyTQcWq3vCUoMrWoCWhvAeY3KKbuTwKDdxU7BBr04CDaqTXk7f56myCcrX2KPxCAs8FNhUcYkWGvkxijWqQ4v0cmRp2lI86Elc6JtXToLK7VAoq1uHKSuOVHNYBilx/uJHEKNLfk0AWmvf8QJ4hTws7qDk0EFVXkz+oeVgQU1Qa5mXrWdvnihDW1D5rrrDW1U5wLqqb20vOGdc3sNujPNTWzH6MX2GzsWPkfBLU1i3+dVUIAAAAASUVORK5CYII="
alt="右链条" class="connection-chain right" />
</view>
<view>
<wd-icon name="info-o" class="tips-icon" />
<text class="tips-title">温馨提示</text>
</view>
<view>
<wd-text rows="2" :content="content" expand-text="展开" collapse-text="收起" />
</view>
</view>
</template>
<style scoped>
.l-remark-card {
position: relative;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
border-radius: 0.75rem;
background-color: #ffffff;
padding: 24px;
}
.tips-card {
background: var(--van-theme-primary-light);
border-radius: 8px;
padding: 12px;
}
.tips-icon {
color: var(--van-theme-primary);
margin-right: 5px;
}
.tips-title {
font-weight: bold;
font-size: 16px;
}
.tips-content {
font-size: 14px;
color: #333;
}
/* 连接链条样式 */
.connection-line {
position: absolute;
top: -40px;
left: 0;
right: 0;
height: 60px;
z-index: 20;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 10px;
}
.connection-chain {
height: 60px;
object-fit: contain;
}
.connection-chain.left {
width: 80px;
margin-left: -10px;
}
.connection-chain.right {
width: 80px;
margin-right: -10px;
}
</style>

80
src/components/LTable.vue Normal file
View File

@@ -0,0 +1,80 @@
<script setup>
import { computed, onMounted } from 'vue'
// 接收表格数据和类型的 props
const props = defineProps({
data: {
type: Array,
required: true,
},
type: {
type: String,
default: 'purple-pink', // 默认渐变颜色
},
})
// 根据 type 设置不同的渐变颜色(偶数行)
const evenClass = computed(() => {
// 统一使用主题色浅色背景
return 'bg-red-50/40'
})
// 动态计算表头的背景颜色和文本颜色
const headerClass = computed(() => {
// 统一使用主题色浅色背景
return 'bg-red-100'
})
// 斑马纹样式,偶数行带颜色,奇数行没有颜色,且从第二行开始
function zebraClass(index) {
return index % 2 === 1 ? evenClass.value : ''
}
</script>
<template>
<view class="l-table overflow-x-auto">
<table
class="min-w-full border-collapse table-auto text-center text-size-xs"
>
<thead :class="headerClass">
<tr>
<!-- 插槽渲染表头 -->
<slot name="header" />
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in props.data"
:key="index"
:class="zebraClass(index)"
class="border-t"
>
<slot :row="row" />
</tr>
</tbody>
</table>
</view>
</template>
<style scoped>
/* 基础表格样式 */
th {
font-weight: bold;
padding: 12px;
text-align: left;
border: 1px solid #e5e7eb;
}
/* 表格行样式 */
td {
padding: 12px;
border: 1px solid #e5e7eb;
}
table {
width: 100%;
border-spacing: 0;
}
.l-table {
@apply rounded-xl;
overflow: hidden;
}
</style>

27
src/components/LTitle.vue Normal file
View File

@@ -0,0 +1,27 @@
<script setup>
// 接收 props
const props = defineProps({
title: String,
})
const titleClass = computed(() => {
// 统一使用主题色
return 'bg-primary'
})
</script>
<template>
<view class="relative">
<!-- 标题部分 -->
<view :class="titleClass" class="inline-block rounded-lg px-2 py-1 text-white font-bold shadow-md">
{{ title }}
</view>
<!-- 左上角修饰 -->
<view
class="absolute left-0 top-0 h-4 w-4 transform rounded-full bg-white shadow-md -translate-x-2 -translate-y-2"
/>
</view>
</template>
<style scoped></style>

View File

@@ -0,0 +1,356 @@
<script setup>
import { computed, nextTick, ref } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
import { useUserStore } from '@/stores/userStore'
import { setAuthSession } from '@/utils/storage'
const emit = defineEmits(['login-success'])
const dialogStore = useDialogStore()
const userStore = useUserStore()
const phoneNumber = ref('')
const verificationCode = ref('')
const password = ref('')
const isPasswordLogin = ref(false)
const isAgreed = ref(false)
const isCountingDown = ref(false)
const countdown = ref(60)
const verificationCodeInputRef = ref(null)
let timer = null
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(phoneNumber.value)
})
const canLogin = computed(() => {
if (!isPhoneNumberValid.value)
return false
if (isPasswordLogin.value) {
return password.value.length >= 6
}
else {
return verificationCode.value.length === 6
}
})
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: phoneNumber.value, actionType: 'login', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
showToast({ message: '获取成功' })
startCountdown()
nextTick(() => {
verificationCodeInputRef.value?.focus?.()
})
}
else {
showToast({ message: data.value?.msg || '发送失败' })
}
}
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
async function handleLogin() {
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
if (isPasswordLogin.value) {
if (password.value.length < 6) {
showToast({ message: '密码长度不能小于6位' })
return
}
}
else {
if (verificationCode.value.length !== 6) {
showToast({ message: '请输入有效的验证码' })
return
}
}
if (!isAgreed.value) {
showToast({ message: '请先同意用户协议' })
return
}
performLogin()
}
async function performLogin() {
const { data, error } = await useApiFetch('/user/mobileCodeLogin')
.post({ mobile: phoneNumber.value, code: verificationCode.value })
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
setAuthSession(data.value.data)
await userStore.fetchUserInfo()
showToast({ message: '登录成功' })
closeDialog()
emit('login-success')
}
else {
showToast(data.value.msg)
}
}
}
function closeDialog() {
dialogStore.closeLogin()
phoneNumber.value = ''
verificationCode.value = ''
password.value = ''
isPasswordLogin.value = false
isAgreed.value = false
isCountingDown.value = false
countdown.value = 60
if (timer) {
clearInterval(timer)
}
}
function toUserAgreement() {
closeDialog()
uni.navigateTo({ url: '/pages/user-agreement' })
}
function toPrivacyPolicy() {
closeDialog()
uni.navigateTo({ url: '/pages/privacy-policy' })
}
</script>
<template>
<wd-popup v-model="dialogStore.showLogin" round position="bottom" :style="{ maxHeight: '90vh' }" :z-index="2000"
@close="closeDialog">
<view class="login-dialog">
<view class="title-bar">
<view class="title-bar-text">
用户登录
</view>
<wd-icon name="close" class="close-icon" @click="closeDialog" />
</view>
<view class="login-dialog-scroll">
<view class="mb-6 pt-4">
<view class="flex flex-col items-center">
<image class="h-16 w-16 rounded-full shadow" src="/static/images/logo.png" alt="Logo" />
<view class="mt-4 text-3xl text-slate-700 font-bold">
赤眉
</view>
</view>
</view>
<view class="login-form">
<view class="form-item">
<text class="form-label">
手机号
</text>
<wd-input v-model="phoneNumber" class="phone-wd-input" type="number" placeholder="请输入手机号" maxlength="11"
no-border clearable />
</view>
<view v-if="!isPasswordLogin" class="form-item">
<text class="form-label">
验证码
</text>
<view class="verification-input-wrapper">
<wd-input ref="verificationCodeInputRef" v-model="verificationCode" class="verification-wd-input"
placeholder="请输入验证码" maxlength="6" no-border clearable>
<template #suffix>
<wd-button size="small" type="primary" plain :disabled="isCountingDown || !isPhoneNumberValid"
@click="sendVerificationCode">
{{ isCountingDown ? `${countdown}s` : '获取验证码' }}
</wd-button>
</template>
</wd-input>
</view>
</view>
<view v-else class="form-item">
<text class="form-label">
密码
</text>
<wd-input v-model="password" class="phone-wd-input" type="text" show-password placeholder="请输入密码" no-border
clearable />
</view>
<view class="flex items-center justify-end py-1">
<text class="switch-login-type" @click="isPasswordLogin = !isPasswordLogin">
{{ isPasswordLogin ? '验证码登录' : '密码登录' }}
</text>
</view>
<view class="agreement-wrapper">
<wd-checkbox v-model="isAgreed" shape="square" size="18px" />
<text class="agreement-text">
我已阅读并同意
<text class="agreement-link" @click="toUserAgreement">
用户协议
</text>
<text class="agreement-link" @click="toPrivacyPolicy">
隐私政策
</text>
</text>
</view>
<view class="notice-text">
未注册手机号登录后将自动生成账号并且代表您已阅读并同意
</view>
<wd-button class="login-wd-btn" block type="primary" :disabled="!canLogin" @click="handleLogin">
</wd-button>
</view>
</view>
</view>
</wd-popup>
</template>
<style scoped>
.login-dialog {
display: flex;
max-height: 90vh;
flex-direction: column;
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}
.title-bar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #eee;
padding: 12px 16px;
}
.title-bar-text {
font-size: 1rem;
font-weight: 700;
color: #111827;
}
.close-icon {
padding: 4px;
font-size: 20px;
color: #666;
}
.login-dialog-scroll {
flex: 1;
overflow-y: auto;
padding-right: 16px;
padding-left: 16px;
/* 与登录页一致:底部留白 + 安全区刘海屏、Home 条) */
padding-bottom: calc(24px + constant(safe-area-inset-bottom));
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
/* 对齐 pages/login.vue */
.login-form {
margin-top: 0.25rem;
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.96);
padding: 1.5rem 1.25rem;
box-shadow: 0 8px 28px rgba(63, 63, 63, 0.1);
backdrop-filter: blur(4px);
}
.form-item {
margin-bottom: 1.25rem;
display: flex;
align-items: center;
border-radius: 12px;
background-color: #fff;
padding: 0 0.75rem;
}
.form-label {
flex-shrink: 0;
margin-bottom: 0;
margin-right: 1rem;
min-width: 4rem;
font-size: 0.9375rem;
color: #111827;
font-weight: 500;
}
.phone-wd-input {
flex: 1;
}
.verification-input-wrapper {
display: flex;
flex: 1;
align-items: center;
}
.verification-wd-input {
width: 100%;
}
.agreement-wrapper {
display: flex;
align-items: center;
margin-top: 0.25rem;
margin-bottom: 1rem;
}
.agreement-text {
margin-left: 0.5rem;
font-size: 0.75rem;
line-height: 1.4;
color: #6b7280;
}
.agreement-link {
color: #2563eb;
}
.notice-text {
margin-bottom: 1.25rem;
font-size: 0.6875rem;
line-height: 1.5;
color: #9ca3af;
}
.login-wd-btn {
letter-spacing: 0.25rem;
}
.switch-login-type {
font-size: 0.875rem;
color: #2563eb;
}
</style>

495
src/components/Payment.vue Normal file
View File

@@ -0,0 +1,495 @@
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
data: {
type: Object,
required: true,
},
id: {
type: String,
required: true,
},
type: {
type: String,
required: true,
},
})
const { isWeChat } = useEnv()
const isDev = import.meta.env.DEV
/** APP 原生端同时展示微信与支付宝;其它端沿用微信内仅微信、否则仅支付宝 */
const isAppClient = computed(() => {
try {
return uni.getSystemInfoSync().uniPlatform === 'app'
}
catch {
return false
}
})
const appName = import.meta.env.VITE_APP_NAME || 'App'
const appLogo = '/static/images/logo.png'
const wechatPayIcon = '/static/images/wechatpay.svg'
const alipayIcon = '/static/images/alipay.svg'
const show = defineModel()
const selectedPaymentMethod = ref(isWeChat.value ? 'wechat' : 'alipay')
const paymentDisplayTime = ref('')
function toFiniteNumber(value) {
const n = typeof value === 'number' ? value : Number(value)
return Number.isFinite(n) ? n : null
}
const payableAmount = computed(() => {
const candidates = [
props?.data?.sell_price,
props?.data?.price,
props?.data?.amount,
]
for (const item of candidates) {
const normalized = toFiniteNumber(item)
if (normalized !== null)
return normalized
}
return null
})
const displayAmount = computed(() => {
if (payableAmount.value === null)
return '--'
return payableAmount.value.toFixed(2)
})
const displayDiscountAmount = computed(() => {
if (payableAmount.value === null)
return '--'
return (payableAmount.value * 0.2).toFixed(2)
})
/** 支付弹窗展示用YYYY-MM-DD HH:mm:ss */
function formatPaymentTime(d = new Date()) {
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
function setDefaultPaymentMethod() {
if (isAppClient.value) {
selectedPaymentMethod.value = 'alipay'
return
}
selectedPaymentMethod.value = isWeChat.value ? 'wechat' : 'alipay'
}
onMounted(setDefaultPaymentMethod)
watch(show, (v) => {
if (v) {
setDefaultPaymentMethod()
paymentDisplayTime.value = formatPaymentTime()
}
})
const router = useRouter()
const discountPrice = ref(false)
/** APP 端 wxpay服务端返回的 prepay_data 对象,供 uni.requestPayment 使用 */
function normalizeWxAppOrderInfo(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
return null
return raw
}
async function getPayment() {
const { data, error } = await useApiFetch('/pay/payment')
.post({
id: props.id,
pay_method: selectedPaymentMethod.value,
pay_type: props.type,
})
.json()
if (!data.value || error.value)
return
if (data.value.code !== 200)
return
const respData = data.value.data
const prepayId = respData.prepay_id
const prepayData = respData.prepay_data
const orderNoFromResp = respData.order_no
if (prepayId === 'test_payment_success') {
if (selectedPaymentMethod.value === 'alipay' || selectedPaymentMethod.value === 'wechat') {
showToast({ message: '支付参数异常,请重试', type: 'fail' })
return
}
show.value = false
router.push({
path: '/payment/result',
query: { orderNo: orderNoFromResp },
})
return
}
// APP 原生:支付宝 / 微信uni.requestPayment
if (isAppClient.value) {
if (selectedPaymentMethod.value === 'alipay') {
if (!prepayId || typeof prepayId !== 'string') {
showToast({ message: '支付宝下单参数异常', type: 'fail' })
return
}
uni.requestPayment({
provider: 'alipay',
orderInfo: prepayId,
success: () => {
show.value = false
router.push({
path: '/payment/result',
query: { orderNo: orderNoFromResp },
})
},
fail: (e) => {
const msg = (e && (e.errMsg || e.message)) || '支付未完成'
showToast({ message: String(msg), type: 'fail' })
},
})
return
}
if (selectedPaymentMethod.value === 'wechat') {
const orderInfo = normalizeWxAppOrderInfo(prepayData)
if (!orderInfo) {
showToast({ message: '微信支付参数异常', type: 'fail' })
return
}
uni.requestPayment({
provider: 'wxpay',
orderInfo,
success: () => {
show.value = false
router.push({
path: '/payment/result',
query: { orderNo: orderNoFromResp },
})
},
fail: (e) => {
const msg = (e && (e.errMsg || e.message)) || '支付未完成'
showToast({ message: String(msg), type: 'fail' })
},
})
return
}
}
if (selectedPaymentMethod.value === 'alipay') {
if (typeof document === 'undefined') {
showToast({ message: '当前环境不支持网页支付宝支付', type: 'fail' })
return
}
const prepayUrl = prepayId
const paymentForm = document.createElement('form')
paymentForm.method = 'POST'
paymentForm.action = prepayUrl
paymentForm.style.display = 'none'
document.body.appendChild(paymentForm)
paymentForm.submit()
show.value = false
return
}
const payload = prepayData
if (typeof WeixinJSBridge === 'undefined') {
showToast({ message: '请在微信内打开以完成支付', type: 'fail' })
return
}
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
payload,
(res) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
show.value = false
router.push({
path: '/payment/result',
query: { orderNo: orderNoFromResp },
})
}
},
)
}
function onCancel() {
show.value = false
}
</script>
<template>
<wd-popup
v-model="show"
round
position="bottom"
:safe-area-inset-bottom="true"
:z-index="2000"
custom-style="max-height: 88vh;"
>
<view class="payment-popup">
<view class="payment-popup__header">
<text class="payment-popup__title">
支付
</text>
</view>
<view class="payment-popup__brand">
<image class="payment-popup__logo" :src="appLogo" mode="aspectFit" />
<text class="payment-popup__app-name">
{{ appName }}
</text>
</view>
<view class="payment-popup__meta">
<view class="payment-popup__row">
<text class="payment-popup__label">
支付项目
</text>
<text class="payment-popup__value">
{{ data.product_name }}
</text>
</view>
<view class="payment-popup__row">
<text class="payment-popup__label">
支付时间
</text>
<text class="payment-popup__value">
{{ paymentDisplayTime }}
</text>
</view>
</view>
<view class="payment-popup__amount-block">
<view class="payment-popup__amount-label">
应付金额
</view>
<view class="payment-popup__amount-price">
<view v-if="discountPrice" class="payment-popup__strike">
¥ {{ displayAmount }}
</view>
<view>
¥
{{
discountPrice
? displayDiscountAmount
: displayAmount
}}
</view>
</view>
<view v-if="discountPrice" class="payment-popup__discount-tip">
活动价2折优惠
</view>
</view>
<view class="payment-popup__methods">
<text class="payment-popup__section-label">
支付方式
</text>
<wd-radio-group v-model="selectedPaymentMethod" shape="dot" cell class="payment-radio-group">
<wd-radio v-if="isAppClient || isWeChat" value="wechat">
<view class="payment-radio-row">
<image class="payment-radio-row__pay-icon" :src="wechatPayIcon" mode="aspectFit" />
<text>
微信支付
</text>
</view>
</wd-radio>
<wd-radio v-if="isAppClient || !isWeChat" value="alipay">
<view class="payment-radio-row">
<image class="payment-radio-row__pay-icon" :src="alipayIcon" mode="aspectFit" />
<text>
支付宝支付
</text>
</view>
</wd-radio>
<wd-radio v-if="isDev" value="test">
<view class="payment-radio-row">
<wd-icon size="24" name="description" color="#ff976a" class="payment-radio-row__icon" />
<text>
开发环境测试支付
</text>
</view>
</wd-radio>
</wd-radio-group>
</view>
<view class="payment-popup__actions">
<!-- eslint-disable-next-line unocss/order-attributify -- 组件属性 block UnoCSS -->
<wd-button block round size="large" type="primary" custom-class="payment-btn-primary" @click="getPayment">
确认支付
</wd-button>
<!-- eslint-disable-next-line unocss/order-attributify -- 组件属性 block UnoCSS -->
<wd-button block round size="small" type="text" custom-class="payment-btn-cancel" @click="onCancel">
取消
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style scoped>
.payment-popup {
display: flex;
flex-direction: column;
max-height: 85vh;
padding: 32rpx 32rpx 24rpx;
box-sizing: border-box;
}
.payment-popup__header {
margin-bottom: 24rpx;
text-align: center;
}
.payment-popup__title {
font-size: 36rpx;
font-weight: 700;
color: #333;
}
.payment-popup__brand {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
margin-bottom: 28rpx;
}
.payment-popup__logo {
width: 72rpx;
height: 72rpx;
border-radius: 16rpx;
}
.payment-popup__app-name {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.payment-popup__meta {
margin-bottom: 28rpx;
padding: 24rpx;
border-radius: 16rpx;
background: #f7f8fa;
}
.payment-popup__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
padding: 12rpx 0;
font-size: 26rpx;
}
.payment-popup__row+.payment-popup__row {
border-top: 1rpx solid #ebedf0;
}
.payment-popup__label {
flex-shrink: 0;
color: #969799;
}
.payment-popup__value {
flex: 1;
text-align: right;
color: #323233;
word-break: break-all;
}
.payment-popup__amount-block {
margin-bottom: 28rpx;
text-align: center;
}
.payment-popup__amount-label {
margin-bottom: 8rpx;
font-size: 26rpx;
color: #969799;
}
.payment-popup__amount-price {
margin-top: 12rpx;
font-size: 48rpx;
font-weight: 700;
color: #ee0a24;
}
.payment-popup__strike {
margin-bottom: 8rpx;
font-size: 28rpx;
color: #969799;
text-decoration: line-through;
}
.payment-popup__discount-tip {
margin-top: 8rpx;
font-size: 24rpx;
color: #ee0a24;
}
.payment-popup__section-label {
display: block;
margin-bottom: 16rpx;
font-size: 28rpx;
font-weight: 600;
color: #323233;
}
.payment-popup__methods {
margin-bottom: 32rpx;
}
.payment-radio-group {
overflow: hidden;
border-radius: 16rpx;
}
.payment-radio-row {
display: flex;
flex-direction: row;
align-items: center;
}
.payment-radio-row__pay-icon {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
margin-right: 16rpx;
}
.payment-popup__actions {
margin-top: auto;
padding-top: 8rpx;
}
:deep(.payment-btn-primary) {
height: 96rpx !important;
font-size: 34rpx !important;
font-weight: 600;
}
:deep(.payment-btn-cancel) {
margin-top: 16rpx;
color: #969799 !important;
font-size: 28rpx !important;
}
</style>

View File

@@ -0,0 +1,248 @@
<script setup>
const props = defineProps({
defaultPrice: {
type: Number,
default: 0,
},
productConfig: {
type: Object,
default: null,
},
})
const emit = defineEmits(['change'])
const { defaultPrice, productConfig } = toRefs(props)
const show = defineModel('show')
const price = ref(null)
const hasProductConfig = computed(() => {
return !!productConfig.value
})
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
watch(show, (visible) => {
if (!visible)
return
price.value = Number(defaultPrice.value || 0)
})
const costPrice = computed(() => {
if (!productConfig.value)
return 0.00
// 平台定价成本
let platformPricing = 0
platformPricing += productConfig.value.cost_price
if (price.value > productConfig.value.p_pricing_standard) {
platformPricing += (price.value - productConfig.value.p_pricing_standard) * productConfig.value.p_overpricing_ratio
}
if (productConfig.value.a_pricing_standard > platformPricing && productConfig.value.a_pricing_end > platformPricing && productConfig.value.a_overpricing_ratio > 0) {
if (price.value > productConfig.value.a_pricing_standard) {
if (price.value > productConfig.value.a_pricing_end) {
platformPricing += (productConfig.value.a_pricing_end - productConfig.value.a_pricing_standard) * productConfig.value.a_overpricing_ratio
}
else {
platformPricing += (price.value - productConfig.value.a_pricing_standard) * productConfig.value.a_overpricing_ratio
}
}
}
return safeTruncate(platformPricing)
})
const promotionRevenue = computed(() => {
return safeTruncate(price.value - costPrice.value)
})
// 价格校验与修正逻辑
function validatePrice(currentPrice) {
if (!productConfig.value) {
return { newPrice: Number(defaultPrice.value || 0), message: '产品配置未就绪,请稍后再试' }
}
const min = productConfig.value.price_range_min
const max = productConfig.value.price_range_max
let newPrice = Number(currentPrice)
let message = ''
// 处理无效输入
if (Number.isNaN(newPrice)) {
newPrice = defaultPrice.value
return { newPrice, message: '输入无效,请输入价格' }
}
// 处理小数位数(兼容科学计数法)
try {
const priceString = newPrice.toString()
const [_, decimalPart = ''] = priceString.split('.')
// 当小数位数超过2位时处理
if (decimalPart.length > 2) {
newPrice = Number.parseFloat(safeTruncate(newPrice))
message = '价格已自动格式化为两位小数'
}
}
catch (e) {
console.error('价格格式化异常:', e)
}
// 范围校验(基于可能格式化后的值)
if (newPrice < min) {
message = `价格不能低于 ${min}`
newPrice = min
}
else if (newPrice > max) {
message = `价格不能高于 ${max}`
newPrice = max
}
return { newPrice, message }
}
function safeTruncate(num, decimals = 2) {
if (Number.isNaN(num) || !Number.isFinite(num))
return '0.00'
const factor = 10 ** decimals
const scaled = Math.trunc(num * factor)
const truncated = scaled / factor
return truncated.toFixed(decimals)
}
const isManualConfirm = ref(false)
function onConfirm() {
if (!hasProductConfig.value) {
showToast('产品配置未就绪,请稍后再试')
return
}
if (isManualConfirm.value)
return
const { newPrice, message } = validatePrice(price.value)
if (message) {
price.value = newPrice
showToast({ message })
}
else {
emit('change', price.value)
show.value = false
}
}
function onBlurPrice() {
const { newPrice, message } = validatePrice(price.value)
if (message) {
isManualConfirm.value = true
price.value = newPrice
showToast({ message })
}
setTimeout(() => {
isManualConfirm.value = false
}, 0)
}
</script>
<template>
<wd-popup v-model="show" destroy-on-close round position="bottom">
<view class="min-h-[500px] bg-gray-50 text-gray-600">
<view class="h-10 flex items-center justify-center bg-white text-lg font-semibold">
设置客户查询价
</view>
<view class="card m-4">
<view class="flex items-center justify-between">
<view class="text-lg">
客户查询价 ()
</view>
</view>
<view class="price-input-wrap border border-orange-100 rounded-xl bg-orange-50/40 px-2">
<wd-input
v-model="price"
type="number"
label="¥"
size="large"
label-width="34"
no-border
custom-input-class="price-input-inner"
custom-label-class="price-input-label"
:placeholder="`${productConfig?.price_range_min || 0} - ${productConfig?.price_range_max || 0}`"
@blur="onBlurPrice"
/>
</view>
<view class="mt-2 flex items-center justify-between">
<view>
推广收益为
<text class="text-orange-500">
{{ promotionRevenue }}
</text>
</view>
<view>
我的成本为
<text class="text-orange-500">
{{ costPrice }}
</text>
</view>
</view>
</view>
<view class="card m-4">
<view class="mb-2 text-lg">
收益与成本说明
</view>
<view>推广收益 = 客户查询价 - 我的成本</view>
<view>我的成本 = 提价成本 + 底价成本</view>
<view class="mt-1">
提价成本超过平台标准定价部分平台会收取部分成本价
</view>
<view>
设定范围
<text class="text-orange-500">
{{ productConfig.price_range_min }}
</text>
-
<text class="text-orange-500">
{{ productConfig.price_range_max }}
</text>
</view>
</view>
<view class="px-4 pb-4">
<wd-button class="w-full" round type="primary" size="large" @click="onConfirm">
确认
</wd-button>
</view>
</view>
</wd-popup>
</template>
<style lang="scss" scoped>
.price-input-wrap {
box-shadow: inset 0 0 0 1px rgba(251, 146, 60, 0.08);
}
.price-input-wrap :deep(.price-input-label) {
color: #ea580c;
font-size: 22px;
font-weight: 700;
}
.price-input-wrap :deep(.price-input-inner) {
height: 56px;
font-size: 40px;
font-weight: 700;
color: #111827;
line-height: 56px;
text-align: left !important;
}
.price-input-wrap :deep(.wd-input__placeholder) {
font-size: 20px;
text-align: left !important;
}
</style>

1098
src/components/QRcode.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,408 @@
<script setup>
import { computed, onUnmounted, ref } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const router = useRouter()
const dialogStore = useDialogStore()
const agentStore = useAgentStore()
const userStore = useUserStore()
// 表单数据
const realName = ref('')
const idCard = ref('')
const phoneNumber = ref('')
const verificationCode = ref('')
const isAgreed = ref(false)
// 倒计时相关
const isCountingDown = ref(false)
const countdown = ref(60)
let timer = null
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
// 表单验证
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(phoneNumber.value)
})
const isIdCardValid = computed(() => {
return /^(?:\d{15}|\d{17}[\dXx])$/.test(idCard.value)
})
const isRealNameValid = computed(() => {
return /^[\u4E00-\u9FA5]{2,}$/.test(realName.value)
})
const canSubmit = computed(() => {
return (
isPhoneNumberValid.value
&& isIdCardValid.value
&& isRealNameValid.value
&& verificationCode.value.length === 6
&& isAgreed.value
)
})
// 发送验证码
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: phoneNumber.value, actionType: 'realName', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
showToast({ message: '获取成功' })
startCountdown()
}
else {
showToast(data.value?.msg || '发送失败')
}
}
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
// 提交实名认证
async function handleSubmit() {
if (!isRealNameValid.value) {
showToast({ message: '请输入有效的姓名' })
return
}
if (!isIdCardValid.value) {
showToast({ message: '请输入有效的身份证号' })
return
}
if (!isPhoneNumberValid.value) {
showToast({ message: '请输入有效的手机号' })
return
}
if (verificationCode.value.length !== 6) {
showToast({ message: '请输入有效的验证码' })
return
}
if (!isAgreed.value) {
showToast({ message: '请先同意用户协议' })
return
}
const { data, error } = await useApiFetch('/agent/real_name')
.post({
name: realName.value,
id_card: idCard.value,
mobile: phoneNumber.value,
code: verificationCode.value,
})
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
showToast({ message: '认证成功' })
agentStore.isRealName = true
await agentStore.fetchAgentStatus()
await userStore.fetchUserInfo()
closeDialog()
}
else {
showToast(data.value.msg)
}
}
}
function closeDialog() {
dialogStore.closeRealNameAuth()
realName.value = ''
idCard.value = ''
phoneNumber.value = ''
verificationCode.value = ''
isAgreed.value = false
if (timer) {
clearInterval(timer)
timer = null
}
isCountingDown.value = false
}
function toUserAgreement() {
closeDialog()
router.push(`/userAgreement`)
}
function toPrivacyPolicy() {
closeDialog()
router.push(`/privacyPolicy`)
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<template>
<view v-if="dialogStore.showRealNameAuth" class="real-name-auth-dialog-box">
<wd-popup
v-model="dialogStore.showRealNameAuth"
round
position="bottom"
:style="{ maxHeight: '90vh' }"
@close="closeDialog"
>
<view
class="real-name-auth-dialog"
style="background: linear-gradient(135deg, var(--van-theme-primary-light), rgba(255,255,255,0.9));"
>
<view class="title-bar">
<view class="text-base font-bold sm:text-lg" style="color: var(--van-text-color);">
实名认证
</view>
<wd-icon name="cross" class="close-icon" style="color: var(--van-text-color-2);" @click="closeDialog" />
</view>
<view class="dialog-content">
<view class="dialog-inner px-4 pb-4 pt-2">
<view
class="auth-notice mb-4 rounded-xl p-3 sm:p-4"
style="background-color: var(--van-theme-primary-light); border: 1px solid rgba(162, 37, 37, 0.2);"
>
<view class="text-xs space-y-1.5 sm:text-sm sm:space-y-2" style="color: var(--van-text-color);">
<text class="font-medium" style="color: var(--van-theme-primary);">
实名认证说明
</text>
<text>1. 实名认证是提现的必要条件</text>
<text>2. 提现金额将转入您实名认证的银行卡账户</text>
<text>3. 请确保填写的信息真实有效否则将影响提现功能的使用</text>
<text>4. 认证信息提交后将无法修改请仔细核对</text>
</view>
</view>
<view class="real-name-form">
<view class="form-item">
<text class="form-label">
姓名
</text>
<wd-input
v-model="realName"
class="field-wd-input"
type="text"
placeholder="请输入真实姓名"
no-border
clearable
/>
</view>
<view class="form-item">
<text class="form-label">
身份证
</text>
<wd-input
v-model="idCard"
class="field-wd-input"
type="text"
placeholder="请输入身份证号"
maxlength="18"
no-border
clearable
/>
</view>
<view class="form-item">
<text class="form-label">
手机号
</text>
<wd-input
v-model="phoneNumber"
class="field-wd-input"
type="number"
placeholder="请输入手机号"
maxlength="11"
no-border
clearable
/>
</view>
<view class="form-item">
<text class="form-label">
验证码
</text>
<view class="verification-input-wrapper">
<wd-input
v-model="verificationCode"
class="verification-wd-input"
placeholder="请输入验证码"
maxlength="6"
no-border
clearable
>
<template #suffix>
<wd-button
size="small"
type="primary"
plain
:disabled="isCountingDown || !isPhoneNumberValid"
@click="sendVerificationCode"
>
{{ isCountingDown ? `${countdown}s` : '获取验证码' }}
</wd-button>
</template>
</wd-input>
</view>
</view>
<view class="agreement-wrapper">
<wd-checkbox v-model="isAgreed" shape="square" size="18px" />
<text class="agreement-text">
我已阅读并同意
<text class="agreement-link" @click="toUserAgreement">
用户协议
</text>
<text class="agreement-link" @click="toPrivacyPolicy">
隐私政策
</text>
并确认以上信息真实有效将用于提现等资金操作
</text>
</view>
<wd-button
class="submit-wd-btn"
block
type="primary"
:disabled="!canSubmit"
@click="handleSubmit"
>
确认认证
</wd-button>
</view>
</view>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.real-name-auth-dialog {
max-height: 90vh;
display: flex;
flex-direction: column;
}
.dialog-content {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.title-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--van-border-color);
flex-shrink: 0;
}
.close-icon {
font-size: 18px;
cursor: pointer;
}
@media (min-width: 640px) {
.close-icon {
font-size: 20px;
}
}
/* 与登录页 login.vue 表单卡片一致 */
.real-name-form {
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 28px rgba(63, 63, 63, 0.08);
padding: 1.25rem 1rem 1.5rem;
backdrop-filter: blur(4px);
}
.form-item {
margin-bottom: 1rem;
display: flex;
align-items: center;
border-radius: 12px;
background-color: #fff;
padding: 0 0.75rem;
min-height: 48px;
}
.form-label {
font-size: 0.9375rem;
color: #111827;
margin-right: 0.75rem;
font-weight: 500;
min-width: 3.25rem;
flex-shrink: 0;
}
.field-wd-input {
flex: 1;
}
.verification-input-wrapper {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.verification-wd-input {
width: 100%;
}
.agreement-wrapper {
display: flex;
align-items: flex-start;
margin-top: 1rem;
margin-bottom: 1rem;
}
.agreement-text {
font-size: 0.75rem;
color: #6b7280;
line-height: 1.5;
margin-left: 0.5rem;
flex: 1;
}
.agreement-link {
color: #2563eb;
cursor: pointer;
}
.submit-wd-btn {
margin-top: 0.25rem;
}
.real-name-auth-dialog-box {
position: relative;
}
</style>

View File

@@ -0,0 +1,19 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
})
</script>
<template>
<view class="flex items-center">
<view class="flex items-center gap-2">
<view class="bg-primary h-5 w-1.5 rounded-xl" />
<view class="text-lg text-gray-800">
{{ title }}
</view>
</view>
</view>
</template>

View File

@@ -0,0 +1,146 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
orderId: {
type: String,
default: '',
},
orderNo: {
type: String,
default: '',
},
isExample: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const isLoading = ref(false)
function showToast(options) {
const message = typeof options === 'string' ? options : (options?.message || options?.title || '')
if (!message)
return
uni.showToast({
title: message,
icon: options?.type === 'success' ? 'success' : 'none',
})
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
showToast({
type: 'success',
message: '链接已复制到剪贴板',
position: 'bottom',
})
}
async function handleShare() {
if (isLoading.value || props.disabled)
return
// 如果是示例模式直接分享当前URL
if (props.isExample) {
try {
const currentUrl = window.location.href
await copyToClipboard(currentUrl)
showToast({
type: 'success',
message: '示例链接已复制到剪贴板',
position: 'bottom',
})
}
catch {
showToast({
type: 'fail',
message: '复制链接失败',
position: 'bottom',
})
}
return
}
// 优先使用 orderId如果没有则使用 orderNo
const orderIdentifier = props.orderId || props.orderNo
if (!orderIdentifier) {
showToast({
type: 'fail',
message: '缺少订单标识',
position: 'bottom',
})
return
}
isLoading.value = true
try {
// 根据实际使用的标识构建请求参数
const requestData = props.orderId
? { order_id: Number.parseInt(props.orderId) }
: { order_no: props.orderNo }
const { data, error } = await useApiFetch('/query/generate_share_link')
.post(requestData)
.json()
if (error.value) {
throw new Error(error.value)
}
if (data.value?.code === 200 && data.value.data?.share_link) {
const baseUrl = window.location.origin
const linkId = encodeURIComponent(data.value.data.share_link)
const fullShareUrl = `${baseUrl}/report/share/${linkId}`
try {
const { confirm } = await uni.showModal({
title: '分享链接已生成',
content: '链接将在7天后过期是否复制到剪贴板',
confirmText: '复制链接',
cancelText: '取消',
})
if (!confirm)
return
await copyToClipboard(fullShareUrl)
}
catch (dialogErr) {
console.error(dialogErr)
}
}
else {
throw new Error(data.value?.message || '生成分享链接失败')
}
}
catch (err) {
showToast({
type: 'fail',
message: err.message || '生成分享链接失败',
position: 'bottom',
})
}
finally {
isLoading.value = false
}
}
</script>
<template>
<view
class="bg-primary border-primary hover:bg-primary-600 flex cursor-pointer items-center justify-center border rounded-[40px] px-3 py-1 transition-colors duration-200"
:class="{ 'opacity-50 cursor-not-allowed': isLoading || disabled }" @click="handleShare"
>
<image src="/static/images/report/fx.png" alt="分享" class="mr-1 h-4 w-4" />
<text class="text-sm text-white font-medium">
{{ isLoading ? "生成中..." : (isExample ? "分享示例" : "分享报告") }}
</text>
</view>
</template>
<style lang="scss" scoped>
/* 样式已通过 Tailwind CSS 类实现 */
</style>

View File

@@ -0,0 +1,44 @@
<script setup>
// 透传所有属性和事件到 van-tabs
defineOptions({
inheritAttrs: false,
})
</script>
<template>
<wd-tabs v-bind="$attrs" type="card" class="styled-tabs">
<slot />
</wd-tabs>
</template>
<style scoped>
/* van-tabs 卡片样式定制 - 仅用于此组件 */
.styled-tabs:deep(.van-tabs__line) {
background-color: var(--van-theme-primary) !important;
}
.styled-tabs:deep(.van-tabs__nav) {
gap: 10px;
}
.styled-tabs:deep(.van-tabs__nav--card) {
border: unset !important;
}
.styled-tabs:deep(.van-tab--card) {
color: #666666 !important;
border-right: unset !important;
background-color: #eeeeee !important;
border-radius: 8px !important;
}
.styled-tabs:deep(.van-tab--active) {
color: white !important;
background-color: var(--van-theme-primary) !important;
}
.styled-tabs:deep(.van-tabs__wrap) {
background-color: #ffffff !important;
padding: 9px 0;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup>
// 不需要额外的 props 或逻辑,只是一个简单的样式组件
</script>
<template>
<view class="title-banner">
<slot />
</view>
</template>
<style scoped>
.title-banner {
@apply mx-auto mt-2 w-64 py-1.5 text-center text-white font-bold text-lg relative rounded-2xl;
background: var(--color-primary);
border: 1px solid var(--color-primary-300);
background-image:
linear-gradient(45deg, transparent 25%, rgba(255, 255, 255, 0.1) 25%, rgba(255, 255, 255, 0.1) 50%, transparent 50%, transparent 75%, rgba(255, 255, 255, 0.1) 75%);
background-size: 20px 20px;
background-position: 0 0;
position: relative;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,187 @@
<script setup>
import LTitle from './LTitle.vue'
import ShareReportButton from './ShareReportButton.vue'
const props = defineProps({
reportParams: {
type: Object,
required: true,
},
reportDateTime: {
type: [String, null],
required: false,
default: null,
},
reportName: {
type: String,
required: true,
},
isEmpty: {
type: Boolean,
required: true,
},
isShare: {
type: Boolean,
required: false,
default: false,
},
orderId: {
type: [String, Number],
required: false,
default: null,
},
orderNo: {
type: String,
required: false,
default: null,
},
})
// 脱敏函数
function maskValue(type, value) {
if (!value)
return value
if (type === 'name') {
// 姓名脱敏(保留首位)
if (value.length === 1) {
return '*' // 只保留一个字,返回 "*"
}
else if (value.length === 2) {
return `${value[0]}*` // 两个字,保留姓氏,第二个字用 "*" 替代
}
else {
return (
value[0]
+ '*'.repeat(value.length - 2)
+ value[value.length - 1]
) // 两个字以上,保留第一个和最后一个字,其余的用 "*" 替代
}
}
else if (type === 'id_card') {
// 身份证号脱敏保留前6位和最后4位
return value.replace(/^(.{6})\d+(.{4})$/, '$1****$2')
}
else if (type === 'mobile') {
if (value.length === 11) {
return `${value.substring(0, 3)}****${value.substring(7)}`
}
return value // 如果手机号不合法或长度不为 11 位,直接返回原手机号
}
return value
}
</script>
<template>
<view class="card" style="padding-left: 0; padding-right: 0; padding-bottom: 24px;">
<view class="flex flex-col gap-y-2">
<!-- 报告信息 -->
<view class="flex items-center justify-between py-2">
<LTitle title="报告信息" />
<!-- 分享按钮 -->
<ShareReportButton
v-if="!isShare" :order-id="orderId" :order-no="orderNo" :is-example="!orderId"
class="mr-4"
/>
</view>
<view class="mx-4 my-2 flex flex-col gap-2">
<view class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">报告时间</text>
<text class="text-gray-600">{{
reportDateTime
|| "2025-01-01 12:00:00"
}}</text>
</view>
<view v-if="!isEmpty" class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">报告项目</text>
<text class="text-gray-600 font-bold">
{{ reportName }}</text>
</view>
</view>
<!-- 报告对象 -->
<template v-if="Object.keys(reportParams).length != 0">
<LTitle title="报告对象" />
<view class="mx-4 my-2 flex flex-col gap-2">
<!-- 姓名 -->
<view v-if="reportParams?.name" class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">姓名</text>
<text class="text-gray-600">{{
maskValue(
"name",
reportParams?.name,
)
}}</text>
</view>
<!-- 身份证号 -->
<view v-if="reportParams?.id_card" class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">身份证号</text>
<text class="text-gray-600">{{
maskValue(
"id_card",
reportParams?.id_card,
)
}}</text>
</view>
<!-- 手机号 -->
<view v-if="reportParams?.mobile" class="flex pb-2 pl-2">
<text class="w-[6em] text-[#666666]">手机号</text>
<text class="text-gray-600">{{
maskValue(
"mobile",
reportParams?.mobile,
)
}}</text>
</view>
<!-- 验证卡片 -->
<view class="mt-4 flex flex-col gap-4">
<!-- 身份证检查结果 -->
<view class="flex flex-1 items-center border border-[#EEEEEE] rounded-lg bg-[#F9F9F9] px-4 py-3">
<view class="mr-4 h-11 w-11 flex items-center justify-center">
<image src="/static/images/report/sfz.png" alt="身份证" class="h-10 w-10 object-contain" />
</view>
<view class="flex-1">
<view class="text-lg text-gray-800 font-bold">
身份证检查结果
</view>
<view class="text-sm text-[#999999]">
身份证信息核验通过
</view>
</view>
<view class="ml-4 h-11 w-11 flex items-center justify-center">
<image src="/static/images/report/zq.png" alt="资金安全" class="h-10 w-10 object-contain" />
</view>
</view>
<!-- 手机号检测结果 -->
<!-- <view class="flex items-center px-4 py-3 flex-1 border border-[#EEEEEE] rounded-lg bg-[#F9F9F9]">
<view class="w-11 h-11 flex items-center justify-center mr-4">
<image src="/static/images/report/sjh.png" alt="手机号" class="w-10 h-10 object-contain" />
</view>
<view class="flex-1">
<view class="font-bold text-gray-800 text-lg">
手机号检测结果
</view>
<view class="text-sm text-[#999999]">
被查询人姓名与运营商提供的一致
</view>
<view class="text-sm text-[#999999]">
被查询人身份证与运营商提供的一致
</view>
</view>
<view class="w-11 h-11 flex items-center justify-center ml-4">
<image src="/static/images/report/zq.png" alt="资金安全" class="w-10 h-10 object-contain" />
</view>
</view> -->
</view>
</view>
</template>
</view>
</view>
</template>
<style scoped>
/* 组件样式已通过 Tailwind CSS 类实现 */
</style>

View File

@@ -0,0 +1,13 @@
<script setup>
function toAgentVip() {
uni.navigateTo({ url: '/pages/agent-vip-apply' })
}
</script>
<template>
<view class="mb-4 w-full" @click="toAgentVip">
<image src="/static/images/vip_banner.png" mode="widthFix" class="w-full rounded-xl shadow-lg" alt="" />
</view>
</template>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,235 @@
import { pages } from 'virtual:uni-pages'
/**
* 将 webview 的 vue-router 用法映射到 uni 路由(迁移页面使用)
*/
const pathToPage: Record<string, string> = {
'/': '/pages/index',
'/login': '/pages/login',
'/historyQuery': '/pages/history-query',
'/help': '/pages/help',
'/help/detail': '/pages/help-detail',
'/help/guide': '/pages/help-guide',
'/withdraw': '/pages/withdraw',
'/report': '/pages/report-result-webview',
'/example': '/pages/report-example-webview',
'/app/report': '/pages/report-result-webview',
'/app/example': '/pages/report-example-webview',
'/privacyPolicy': '/pages/privacy-policy',
'/userAgreement': '/pages/user-agreement',
'/authorization': '/pages/authorization',
'/agentManageAgreement': '/pages/agent-manage-agreement',
'/agentSerivceAgreement': '/pages/agent-service-agreement',
'/agentServiceAgreement': '/pages/agent-service-agreement',
'/payment/result': '/pages/payment-result',
'/agent': '/pages/agent',
'/agent/promote': '/pages/promote',
'/me': '/pages/me',
'/cancelAccount': '/pages/cancel-account',
}
const nameToPage: Record<string, string> = {
index: '/pages/index',
login: '/pages/login',
invite: '/pages/invitation',
invitation: '/pages/invitation',
promote: '/pages/promote',
agent: '/pages/agent',
history: '/pages/history-query',
help: '/pages/help',
helpDetail: '/pages/help-detail',
helpGuide: '/pages/help-guide',
withdraw: '/pages/withdraw',
report: '/pages/report-result-webview',
example: '/pages/report-example-webview',
paymentResult: '/pages/payment-result',
privacyPolicy: '/pages/privacy-policy',
userAgreement: '/pages/user-agreement',
authorization: '/pages/authorization',
agentManageAgreement: '/pages/agent-manage-agreement',
agentSerivceAgreement: '/pages/agent-service-agreement',
agentServiceAgreement: '/pages/agent-service-agreement',
me: '/pages/me',
cancelAccount: '/pages/cancel-account',
}
function withQuery(url: string, query?: Record<string, string>) {
if (!query || !Object.keys(query).length)
return url
const q = Object.entries(query)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join('&')
return `${url}${url.includes('?') ? '&' : '?'}${q}`
}
export function resolveWebToUni(to: string | { name?: string, path?: string, query?: Record<string, string> }): string {
if (typeof to === 'string') {
const qIdx = to.indexOf('?')
const pathOnly = qIdx === -1 ? to : to.slice(0, qIdx)
const queryPart = qIdx === -1 ? '' : to.slice(qIdx + 1)
if (pathOnly.startsWith('/inquire/')) {
const feature = pathOnly.replace(/^\/inquire\//, '')
const base = `/pages/inquire?feature=${encodeURIComponent(feature)}`
return queryPart ? `${base}&${queryPart}` : base
}
if (pathOnly.startsWith('/agent/invitationAgentApply/')) {
const id = pathOnly.replace(/^\/agent\/invitationAgentApply\//, '')
const base = `/pages/invitation-agent-apply?linkIdentifier=${encodeURIComponent(id)}`
return queryPart ? `${base}&${queryPart}` : base
}
if (pathOnly.startsWith('/agent/promotionInquire/')) {
const id = pathOnly.replace(/^\/agent\/promotionInquire\//, '')
const base = `/pages/promotion-inquire?linkIdentifier=${encodeURIComponent(id)}`
return queryPart ? `${base}&${queryPart}` : base
}
if (pathOnly.startsWith('/report/share/')) {
const id = pathOnly.replace(/^\/report\/share\//, '')
const base = `/pages/report-share?linkIdentifier=${encodeURIComponent(id)}`
return queryPart ? `${base}&${queryPart}` : base
}
if (pathToPage[pathOnly]) {
const mapped = pathToPage[pathOnly]
return queryPart ? `${mapped}?${queryPart}` : mapped
}
if (to.startsWith('/pages/'))
return to
return `/pages/${pathOnly.replace(/^\//, '')}${queryPart ? `?${queryPart}` : ''}`
}
if (to.path) {
const base = pathToPage[to.path] || to.path
return withQuery(base, to.query)
}
if (to.name && nameToPage[to.name])
return withQuery(nameToPage[to.name], to.query)
return '/pages/index'
}
export function useRouter() {
return {
push(to: string | { name?: string, path?: string, query?: Record<string, string> }) {
const url = resolveWebToUni(to as any)
uni.navigateTo({ url })
},
replace(to: string | { name?: string, path?: string, query?: Record<string, string> }) {
const url = resolveWebToUni(to as any)
uni.redirectTo({ url })
},
go() {
uni.navigateBack({})
},
back() {
uni.navigateBack({})
},
}
}
/** 当前页 uni route如 `pages/history-query`(无首尾多余斜杠) */
export function getCurrentUniRoute(): string {
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
return (page?.route || 'pages/index').replace(/^\//, '')
}
type UniPageMeta = (typeof pages)[number] & {
path?: string
auth?: boolean
style?: {
navigationBarTitleText?: string
}
}
function normalizeUniRoute(route?: string) {
return (route || 'pages/index').replace(/^\//, '')
}
const pageMetaByRoute = new Map(
(pages as UniPageMeta[]).map(page => [normalizeUniRoute(page.path), page]),
)
export function getCurrentPageMeta(): UniPageMeta | undefined {
return pageMetaByRoute.get(getCurrentUniRoute())
}
export function getPageTitleByRoute(route = getCurrentUniRoute()): string {
return pageMetaByRoute.get(normalizeUniRoute(route))?.style?.navigationBarTitleText || 'BDRP'
}
export function getLayoutPageTitle(): string {
return getPageTitleByRoute()
}
/**
* 与 webview vue-router path 对齐,用于全局通知 notificationPage 匹配
*/
const UNI_TO_WEB_NOTIFY_PATH: Record<string, string> = {
'pages/index': '/',
'pages/agent': '/agent',
'pages/me': '/me',
'pages/promote': '/agent/promote',
'pages/history-query': '/historyQuery',
'pages/help': '/help',
'pages/help-detail': '/help/detail',
'pages/help-guide': '/help/guide',
'pages/withdraw': '/withdraw',
'pages/report-result-webview': '/app/report',
'pages/report-example-webview': '/app/example',
'pages/privacy-policy': '/privacyPolicy',
'pages/user-agreement': '/userAgreement',
'pages/agent-manage-agreement': '/agentManageAgreement',
'pages/agent-service-agreement': '/agentSerivceAgreement',
'pages/authorization': '/authorization',
'pages/payment-result': '/payment/result',
'pages/inquire': '/inquire',
'pages/login': '/login',
'pages/invitation': '/agent/invitation',
'pages/agent-promote-details': '/agent/promoteDetails',
'pages/agent-rewards-details': '/agent/rewardsDetails',
'pages/agent-vip': '/agent/agentVip',
'pages/agent-vip-apply': '/agent/vipApply',
'pages/agent-vip-config': '/agent/vipConfig',
'pages/withdraw-details': '/agent/withdrawDetails',
'pages/subordinate-list': '/agent/subordinateList',
}
export function getWebPathForNotification(): string {
const r = getCurrentUniRoute()
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
const q: Record<string, string> = { ...(page?.options || {}) }
if (r === 'pages/inquire' && q.feature)
return `/inquire/${q.feature}`
if (r === 'pages/subordinate-detail' && q.id)
return `/agent/subordinateDetail/${q.id}`
if (r === 'pages/invitation-agent-apply' && q.linkIdentifier)
return `/agent/invitationAgentApply/${q.linkIdentifier}`
if (r === 'pages/promotion-inquire' && q.linkIdentifier)
return `/agent/promotionInquire/${q.linkIdentifier}`
if (r === 'pages/report-share' && q.linkIdentifier)
return `/report/share/${q.linkIdentifier}`
return UNI_TO_WEB_NOTIFY_PATH[r] || '/'
}
const uniRouteToName: Record<string, string> = {
'pages/index': 'index',
'pages/agent': 'agent',
'pages/me': 'me',
'pages/promote': 'promote',
}
export function useRoute() {
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
const query: Record<string, string> = { ...(page?.options || {}) }
const params: Record<string, string> = { ...query }
const routeKey = normalizeUniRoute(page?.route)
return {
query,
path: page?.route ? `/${page.route}` : '/',
params,
name: uniRouteToName[routeKey] || routeKey,
meta: {
title: getPageTitleByRoute(routeKey),
},
}
}

View File

@@ -0,0 +1,196 @@
/**
* 与 bdrp-webview `useApiFetch.js` 行为对齐:链式 `.get().json()` / `.post().json()`
* 返回 `{ data, error }` 的 Ref与迁移过来的页面兼容
*/
import type { Ref } from 'vue'
import { ref } from 'vue'
import { envConfig } from '@/constants/env'
import { useAgentStore } from '@/stores/agentStore'
import { useUserStore } from '@/stores/userStore'
import { navigateLogin } from '@/utils/navigate'
import { clearAuthStorage, getToken } from '@/utils/storage'
export interface ApiEnvelope<T = unknown> {
code: number
msg: string
data: T
}
let loadingCount = 0
function showLoading() {
loadingCount++
if (loadingCount === 1)
uni.showLoading({ title: '加载中...', mask: true })
}
function hideLoading() {
loadingCount = Math.max(0, loadingCount - 1)
if (loadingCount === 0)
uni.hideLoading()
}
function appendTimestamp(url: string) {
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}t=${Date.now()}`
}
function joinApiUrl(path: string) {
const base = envConfig.apiBaseUrl.replace(/\/$/, '')
const p = path.startsWith('/') ? path : `/${path}`
return appendTimestamp(`${base}${p}`)
}
/** 本仓库仅面向 APP与后端 `model.PlatformApp` / ctx `platform` 一致,须为小写 `app` */
const REQUEST_PLATFORM_APP = 'app'
async function handleErrorCode<T>(payload: ApiEnvelope<T>) {
if (payload.code === 100009) {
clearAuthStorage()
const userStore = useUserStore()
const agentStore = useAgentStore()
userStore.resetUser()
agentStore.resetAgent()
uni.reLaunch({ url: '/pages/index' })
return
}
if (payload.code === 100011) {
uni.showToast({ title: payload.msg || '账号已被封禁', icon: 'none' })
clearAuthStorage()
const userStore = useUserStore()
const agentStore = useAgentStore()
userStore.resetUser()
agentStore.resetAgent()
navigateLogin()
return
}
if (payload.code === 100013) {
uni.showToast({ title: payload.msg || '账号已注销', icon: 'none' })
clearAuthStorage()
const userStore = useUserStore()
const agentStore = useAgentStore()
userStore.resetUser()
agentStore.resetAgent()
navigateLogin()
return
}
if (
payload.code !== 200002
&& payload.code !== 200003
&& payload.code !== 200004
&& payload.code !== 100009
&& payload.code !== 100011
&& payload.code !== 100013
) {
uni.showToast({ title: payload.msg || '请求失败', icon: 'none' })
}
}
function uniRequest<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
url: string,
data?: unknown,
): Promise<{ statusCode: number, data: ApiEnvelope<T> }> {
return new Promise((resolve, reject) => {
const token = getToken()
uni.request({
url: joinApiUrl(url),
method,
data: data as Record<string, unknown> | undefined,
header: {
'Content-Type': 'application/json',
'X-Platform': REQUEST_PLATFORM_APP,
...(token ? { Authorization: token } : {}),
},
success: (res) => {
resolve({
statusCode: res.statusCode || 0,
data: res.data as ApiEnvelope<T>,
})
},
fail: (err) => {
reject(err)
},
})
})
}
async function executeJson<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
url: string,
data?: unknown,
): Promise<{ data: Ref<ApiEnvelope<T> | null>, error: Ref<Error | null> }> {
const dataRef = ref<ApiEnvelope<T> | null>(null) as Ref<ApiEnvelope<T> | null>
const errorRef = ref<Error | null>(null)
showLoading()
try {
const { statusCode, data: body } = await uniRequest<T>(method, url, data)
hideLoading()
if (statusCode === 401) {
clearAuthStorage()
navigateLogin()
errorRef.value = new Error('401')
return { data: dataRef, error: errorRef }
}
if (statusCode === 403) {
const b = body as ApiEnvelope<unknown> | null | undefined
const toastTitle = b?.code === 100013
? (b.msg || '账号已注销')
: (b?.msg || '账号已被封禁')
uni.showToast({ title: toastTitle, icon: 'none' })
clearAuthStorage()
useUserStore().resetUser()
useAgentStore().resetAgent()
navigateLogin()
errorRef.value = new Error('403')
return { data: dataRef, error: errorRef }
}
if (!body || typeof body.code !== 'number') {
errorRef.value = new Error('响应格式不正确')
return { data: dataRef, error: errorRef }
}
dataRef.value = body
if (body.code !== 200)
await handleErrorCode(body)
return { data: dataRef, error: errorRef }
}
catch (e) {
hideLoading()
const err = e instanceof Error ? e : new Error(String(e))
errorRef.value = err
uni.showToast({ title: '网络异常,请稍后再试', icon: 'none' })
return { data: dataRef, error: errorRef }
}
}
function chain(url: string) {
return {
get() {
return {
json: <T>() => executeJson<T>('GET', url),
}
},
post(data?: unknown) {
return {
json: <T>() => executeJson<T>('POST', url, data),
}
},
put(data?: unknown) {
return {
json: <T>() => executeJson<T>('PUT', url, data),
}
},
delete() {
return {
json: <T>() => executeJson<T>('DELETE', url),
}
},
}
}
export default function useApiFetch(url: string) {
return chain(url)
}

View File

@@ -0,0 +1,29 @@
import { installNavigationAuthGuard } from '@/composables/useNavigationAuthGuard'
// #ifdef APP-PLUS
import { useHotUpdate } from '@/composables/useHotUpdate'
// #endif
import { useAgentStore } from '@/stores/agentStore'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/userStore'
export function useAppBootstrap() {
const authStore = useAuthStore()
const userStore = useUserStore()
const agentStore = useAgentStore()
const bootstrap = async () => {
installNavigationAuthGuard()
userStore.restoreFromStorage()
agentStore.restoreFromStorage()
if (authStore.hasToken) {
await Promise.allSettled([userStore.fetchUserInfo(), agentStore.fetchAgentStatus()])
}
authStore.markReady()
// #ifdef APP-PLUS
const { checkUpdate } = useHotUpdate()
void checkUpdate()
// #endif
}
return { bootstrap }
}

View File

@@ -0,0 +1,11 @@
import { getToken } from '@/utils/storage'
export function useAuthGuard() {
const ensureLogin = () => {
if (getToken())
return true
uni.navigateTo({ url: '/pages/login' })
return false
}
return { ensureLogin }
}

View File

@@ -0,0 +1,16 @@
export function useCount() {
const count = ref(Math.round(Math.random() * 20))
function inc() {
count.value += 1
}
function dec() {
count.value -= 1
}
return {
count,
inc,
dec,
}
}

View File

@@ -0,0 +1,153 @@
import { envConfig } from '@/constants/env'
function formatErr(e: unknown): string {
if (e == null)
return '未知错误'
if (typeof e === 'string')
return e
if (e instanceof Error)
return e.message
try {
const o = e as Record<string, unknown>
if (typeof o.message === 'string')
return o.message
return JSON.stringify(e)
}
catch {
return String(e)
}
}
function getErrCode(err: unknown): number | undefined {
if (err && typeof err === 'object' && 'code' in err) {
const c = (err as { code: unknown }).code
return typeof c === 'number' ? c : undefined
}
return undefined
}
/** 微信 SDK 返回「接口不支持」类错误(常见于自定义基座未集成客服能力,仍为 -3 */
function isWeixinSdkCustomerServiceUnsupported(err: unknown): boolean {
if (getErrCode(err) === -3)
return true
const m = formatErr(err)
return m.includes('不支持') || m.includes('此功能')
}
/** 用系统能力打开客服链接(唤起微信或系统浏览器处理 work.weixin.qq.com */
function tryOpenUrlWithRuntime(url: string): boolean {
try {
if (typeof plus !== 'undefined' && plus.runtime?.openURL) {
plus.runtime.openURL(
url,
(err: unknown) => {
console.error('[客服] plus.runtime.openURL 失败', err, 'url=', url)
uni.showToast({ title: `无法打开链接: ${formatErr(err)}`, icon: 'none' })
},
)
return true
}
}
catch (e) {
console.error('[客服] plus.runtime.openURL 异常', e)
}
return false
}
/**
* 投诉 / 联系客服App
* - 默认先走微信 `openCustomerServiceChat`;若返回 -3「此功能不支持」则改用 `plus.runtime.openURL` 打开客服链接(企业微信 H5 客服页仍可进线)。
* - 设置 `VITE_CUSTOMER_SERVICE_SKIP_SDK=1` 可跳过 SDK始终用系统打开链接推荐在一直报 -3 的环境使用)。
*/
export function openCustomerService() {
const url = (envConfig.customerServiceUrl || '').trim()
const corpId = (envConfig.wxworkCorpId || '').trim()
if (!url) {
console.error('[客服] 未配置 VITE_CUSTOMER_SERVICE_URL')
uni.showToast({ title: '未配置客服地址', icon: 'none' })
return
}
if (typeof plus === 'undefined') {
console.error('[客服] 非 App 环境,无 5+ Runtime')
uni.showToast({ title: '仅 App 内支持', icon: 'none' })
return
}
if (envConfig.customerServiceSkipSdk) {
console.warn('[客服] 已配置 SKIP_SDK直接使用 plus.runtime.openURL')
uni.showToast({ title: '正在打开客服…', icon: 'none' })
if (!tryOpenUrlWithRuntime(url))
uni.showToast({ title: '无法打开客服链接', icon: 'none' })
return
}
if (!plus.share?.getServices) {
console.error('[客服] plus.share.getServices 不存在')
uni.showToast({ title: '正在打开客服…', icon: 'none' })
if (!tryOpenUrlWithRuntime(url))
uni.showToast({ title: '当前环境无法打开客服', icon: 'none' })
return
}
plus.share.getServices(
(services) => {
console.warn('[客服] getServices 成功', services?.map(s => ({ id: s.id, description: s.description, nativeClient: s.nativeClient })))
const weixin = services?.find(s => String(s.id) === 'weixin')
if (!weixin) {
console.error('[客服] 分享服务列表中无微信(weixin),完整列表:', services)
uni.showToast({ title: '正在打开客服…', icon: 'none' })
tryOpenUrlWithRuntime(url)
return
}
const openChat = (weixin as { openCustomerServiceChat?: (opts: { corpid: string, url: string }, ok?: () => void, fail?: (e: unknown) => void) => void }).openCustomerServiceChat
if (typeof openChat !== 'function') {
console.error('[客服] 无 openCustomerServiceChat 方法', weixin)
uni.showToast({ title: '正在打开客服…', icon: 'none' })
tryOpenUrlWithRuntime(url)
return
}
if (!corpId) {
console.error('[客服] 未配置 VITE_WXWORK_CORP_ID无法调用 openCustomerServiceChat改为 openURL')
uni.showToast({ title: '正在打开客服…', icon: 'none' })
tryOpenUrlWithRuntime(url)
return
}
const opts = { corpid: corpId, url }
console.warn('[客服] 调用 openCustomerServiceChat', opts)
openChat(
opts,
() => {
console.warn('[客服] openCustomerServiceChat 成功')
},
(err: unknown) => {
console.error('[客服] openCustomerServiceChat 失败', err)
if (isWeixinSdkCustomerServiceUnsupported(err)) {
console.warn(
'[客服] 当前运行环境微信 SDK 不支持原生客服接口(常见:自定义基座未集成/需勾选微信客服模块)。已改为用系统打开客服链接。',
err,
)
uni.showToast({ title: '正在打开客服…', icon: 'none' })
tryOpenUrlWithRuntime(url)
return
}
uni.showToast({ title: `拉起客服失败: ${formatErr(err)}`, icon: 'none', duration: 3000 })
tryOpenUrlWithRuntime(url)
},
)
},
(err: unknown) => {
console.error('[客服] getServices 失败', err)
uni.showToast({ title: '正在打开客服…', icon: 'none' })
tryOpenUrlWithRuntime(url)
},
)
}

10
src/composables/useEnv.js Normal file
View File

@@ -0,0 +1,10 @@
import { ref } from 'vue'
/** App 端固定非微信 H5保留 isWeChat 以兼容迁移代码分支 */
const isWeChat = ref(false)
export function useEnv() {
return {
isWeChat,
}
}

View File

@@ -0,0 +1,261 @@
import { ref } from 'vue'
import { envConfig } from '@/constants/env'
export interface AppVersionPayload {
version: string
wgtUrl: string
}
function joinApiPath(path: string): string {
const base = envConfig.apiBaseUrl.replace(/\/$/, '')
const p = path.startsWith('/') ? path : `/${path}`
return `${base}${p}`
}
function compareVersion(v1: string, v2: string): number {
const v1Parts = v1.split('.').map(Number)
const v2Parts = v2.split('.').map(Number)
const len = Math.max(v1Parts.length, v2Parts.length)
for (let i = 0; i < len; i++) {
const a = v1Parts[i] ?? 0
const b = v2Parts[i] ?? 0
if (a > b)
return 1
if (a < b)
return -1
}
return 0
}
/** APP 端 WGT 热更新:请求 `/app/version`,比对版本后静默下载安装 */
export function useHotUpdate() {
const updating = ref(false)
const hasNewVersion = ref(false)
const currentVersion = ref('')
const latestVersion = ref('')
const downloadProgress = ref(0)
const serverWgtUrl = ref('')
const getCurrentVersion = (): Promise<string> => {
return new Promise((resolve) => {
// #ifdef APP-PLUS
const appid = plus.runtime.appid
if (!appid) {
currentVersion.value = '0.0.0'
resolve('0.0.0')
return
}
plus.runtime.getProperty(appid, (inf) => {
const wgtVer = inf.version ?? '0.0.0'
currentVersion.value = wgtVer
resolve(wgtVer)
})
// #endif
// #ifndef APP-PLUS
const defaultVersion = '0.0.0'
currentVersion.value = defaultVersion
resolve(defaultVersion)
// #endif
})
}
/** 不弹业务 toast版本检查失败时静默 */
function fetchAppVersion(): Promise<AppVersionPayload | null> {
return new Promise((resolve) => {
uni.request({
url: joinApiPath('/app/version'),
method: 'GET',
success: (res) => {
const body = res.data as { code?: number, data?: AppVersionPayload }
if (res.statusCode !== 200 || !body || body.code !== 200 || !body.data) {
resolve(null)
return
}
resolve(body.data)
},
fail: () => resolve(null),
})
})
}
const checkVersionOnly = async (): Promise<boolean> => {
try {
await getCurrentVersion()
const serverInfo = await fetchAppVersion()
if (!serverInfo) {
hasNewVersion.value = false
return false
}
latestVersion.value = serverInfo.version
if (serverInfo.wgtUrl)
serverWgtUrl.value = serverInfo.wgtUrl
hasNewVersion.value = compareVersion(serverInfo.version, currentVersion.value) > 0
return hasNewVersion.value
}
catch (e) {
console.error('[wgt] 检查版本失败', e)
return false
}
}
const checkUpdate = async () => {
try {
await getCurrentVersion()
const serverInfo = await fetchAppVersion()
if (!serverInfo)
return
latestVersion.value = serverInfo.version
if (compareVersion(serverInfo.version, currentVersion.value) <= 0) {
hasNewVersion.value = false
return
}
hasNewVersion.value = true
if (serverInfo.wgtUrl) {
serverWgtUrl.value = serverInfo.wgtUrl
silentUpdate(serverInfo.wgtUrl).catch((err) => {
console.error('[wgt] 静默更新失败', err)
})
}
}
catch (e) {
console.error('[wgt] 检查更新失败', e)
}
}
const silentUpdate = (wgtUrl: string): Promise<void> => {
if (updating.value)
return Promise.reject(new Error('更新已在进行中'))
updating.value = true
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
const dtask = plus.downloader.createDownload(
wgtUrl,
{ filename: '_doc/update/' },
(download, status) => {
if (status === 200) {
const fp = download.filename
if (!fp) {
updating.value = false
reject(new Error('下载路径无效'))
return
}
installing(fp)
.then(() => {
updating.value = false
resolve()
})
.catch((err) => {
updating.value = false
reject(err)
})
}
else {
updating.value = false
reject(new Error('下载更新包失败'))
}
},
)
dtask.start()
// #endif
// #ifndef APP-PLUS
updating.value = false
resolve()
// #endif
})
}
const installing = (filePath: string): Promise<void> => {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
plus.runtime.install(
filePath,
{ force: false },
() => {
resolve()
plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
try {
(entry as { remove?: (cb?: () => void) => void }).remove?.()
}
catch {
/* 忽略删除失败 */
}
})
},
(error: unknown) => {
console.error('[wgt] 安装失败', error)
reject(error)
},
)
// #endif
// #ifndef APP-PLUS
resolve()
// #endif
})
}
const manualUpdate = (wgtUrl: string): Promise<void> => {
if (updating.value)
return Promise.reject(new Error('更新已在进行中'))
updating.value = true
downloadProgress.value = 0
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
const dtask = plus.downloader.createDownload(
wgtUrl,
{ filename: '_doc/update/' },
(download, status) => {
if (status === 200) {
const fp = download.filename
if (!fp) {
updating.value = false
reject(new Error('下载路径无效'))
return
}
installing(fp)
.then(() => {
updating.value = false
resolve()
})
.catch((err) => {
updating.value = false
reject(err)
})
}
else {
updating.value = false
reject(new Error('下载更新包失败'))
}
},
)
dtask.addEventListener('statechanged', (task) => {
if (task.state === 3) {
const total = Number(task.totalSize ?? 0)
const downloaded = Number(task.downloadedSize ?? 0)
downloadProgress.value
= total > 0 ? Math.round((downloaded / total) * 100) : 0
}
else if (task.state === 4) {
downloadProgress.value = 100
}
})
dtask.start()
// #endif
// #ifndef APP-PLUS
updating.value = false
resolve()
// #endif
})
}
return {
updating,
hasNewVersion,
currentVersion,
latestVersion,
downloadProgress,
serverWgtUrl,
checkUpdate,
checkVersionOnly,
manualUpdate,
}
}

View File

@@ -0,0 +1,27 @@
import { createFetch, useFetch } from '@vueuse/core'
export function useHttp(url, options = {}, token) {
const fetch = createFetch(url, {
baseUrl: '/api/v1',
options: {
async beforeFetch({ url, options, cancel }) {
console.log('asdasd', options)
options.headers = {
...options.headers,
Authorization: `${token}`,
}
return {
options,
}
},
async afterFetch(ctx) {
console.log('ctx', ctx)
// if (ctx.data.code !== 200) {
// throw new Error(ctx.data.message || '请求失败');
// }
return ctx
},
},
})
return fetch(url)
}

View File

@@ -0,0 +1,163 @@
import { pages } from 'virtual:uni-pages'
import { useAgentStore } from '@/stores/agentStore'
import { getAgentInfo, getToken } from '@/utils/storage'
interface PagePermission {
requiresAuth?: boolean
requiresAgent?: boolean
}
const LOGIN_ROUTE = 'pages/login'
const AGENT_APPLY_ROUTE = 'pages/invitation-agent-apply'
const AGENT_APPLY_URL = '/pages/invitation-agent-apply'
const HOME_URL = '/pages/index'
/**
* 对齐 bdrp-webview/src/router/index.js 的鉴权配置
* requiresAgent 一律隐含 requiresAuth
*/
const ROUTE_PERMISSION_MAP: Record<string, PagePermission> = {
'pages/promote': { requiresAuth: true, requiresAgent: true },
'pages/history-query': { requiresAuth: true },
'pages/withdraw': { requiresAuth: true, requiresAgent: true },
'pages/payment-result': { requiresAuth: true },
'pages/report-result-webview': { requiresAuth: true },
'pages/agent-promote-details': { requiresAuth: true, requiresAgent: true },
'pages/agent-rewards-details': { requiresAuth: true, requiresAgent: true },
'pages/invitation': { requiresAuth: true, requiresAgent: true },
'pages/agent-vip': { requiresAuth: true, requiresAgent: true },
'pages/agent-vip-apply': { requiresAuth: true, requiresAgent: true },
'pages/agent-vip-config': { requiresAuth: true, requiresAgent: true },
'pages/withdraw-details': { requiresAuth: true, requiresAgent: true },
'pages/invitation-agent-apply': { requiresAuth: true },
'pages/subordinate-list': { requiresAuth: true, requiresAgent: true },
'pages/subordinate-detail': { requiresAuth: true, requiresAgent: true },
}
const metaAuthMap = new Map(
(pages as Array<{ path?: string, auth?: boolean }>).map(page => [(page.path || '').replace(/^\//, ''), Boolean(page.auth)]),
)
function normalizePath(path = '') {
return path.replace(/^\//, '').split('?')[0]
}
function parseRouteFromUrl(url = '') {
return normalizePath(url)
}
function getPermission(route: string): PagePermission {
const conf = ROUTE_PERMISSION_MAP[route] || {}
const metaAuth = Boolean(metaAuthMap.get(route))
const requiresAgent = Boolean(conf.requiresAgent)
const requiresAuth = Boolean(conf.requiresAuth || metaAuth || requiresAgent)
return { requiresAuth, requiresAgent }
}
function buildLoginUrl(url: string) {
const redirect = encodeURIComponent(url.startsWith('/') ? url : `/${url}`)
return `/pages/login?redirect=${redirect}`
}
function isAgentUser() {
const agentStore = useAgentStore()
if (agentStore.isLoaded)
return Boolean(agentStore.isAgent)
const agentInfo = getAgentInfo()
if (agentInfo && typeof agentInfo === 'object')
return Boolean((agentInfo as any).isAgent)
return false
}
type GuardMode = 'navigate' | 'redirect'
function goLogin(url: string, mode: GuardMode) {
const targetUrl = buildLoginUrl(url)
if (mode === 'redirect')
uni.redirectTo({ url: targetUrl })
else
uni.navigateTo({ url: targetUrl })
}
function goAgentApply(mode: GuardMode) {
if (mode === 'redirect')
uni.redirectTo({ url: AGENT_APPLY_URL })
else
uni.navigateTo({ url: AGENT_APPLY_URL })
}
export function ensurePageAccessByUrl(url = '', mode: GuardMode = 'navigate') {
const route = parseRouteFromUrl(url)
if (!route || route === LOGIN_ROUTE)
return false
const permission = getPermission(route)
if (!permission.requiresAuth)
return false
if (!getToken()) {
goLogin(url, mode)
return true
}
if (permission.requiresAgent && route !== AGENT_APPLY_ROUTE && !isAgentUser()) {
goAgentApply(mode)
return true
}
return false
}
/** 未套 layout 的页面在 onShow 中与 default 布局一致地做鉴权(如 webview 页使用原生导航栏时) */
export function ensureCurrentPageAccess(mode: GuardMode = 'redirect') {
const stack = getCurrentPages()
const page = stack[stack.length - 1] as { route?: string, options?: Record<string, string> }
if (!page?.route)
return false
const routePath = `/${String(page.route).replace(/^\//, '')}`
const query = page.options || {}
const qs = Object.keys(query).length
? `?${Object.entries(query).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v ?? ''))}`).join('&')}`
: ''
return ensurePageAccessByUrl(`${routePath}${qs}`, mode)
}
let installed = false
export function installNavigationAuthGuard() {
if (installed)
return
installed = true
uni.addInterceptor('navigateTo', {
invoke(args) {
if (ensurePageAccessByUrl(args?.url, 'navigate'))
return false
return args
},
})
uni.addInterceptor('redirectTo', {
invoke(args) {
if (ensurePageAccessByUrl(args?.url, 'redirect'))
return false
return args
},
})
uni.addInterceptor('reLaunch', {
invoke(args) {
if (ensurePageAccessByUrl(args?.url, 'redirect'))
return false
return args
},
})
uni.addInterceptor('switchTab', {
invoke(args) {
if (ensurePageAccessByUrl(args?.url || HOME_URL, 'redirect'))
return false
return args
},
})
}

View File

@@ -0,0 +1,8 @@
export function useQuery(key?: MaybeRefOrGetter<string>) {
const query = ref<AnyObject>({})
onLoad((q) => {
query.value = q || {}
})
const value = computed(() => (key ? query.value[toValue(key)] : null))
return { query, value }
}

View File

@@ -0,0 +1,78 @@
import { envConfig } from '@/constants/env'
import { getToken } from '@/utils/storage'
declare function getCurrentPages(): any[]
/** App 端部分 WebView 无 URLSearchParams用手动编码保证兼容 */
function buildQueryString(params: Record<string, string>): string {
const parts: string[] = []
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === '')
continue
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
}
return parts.join('&')
}
export function useReportWebview() {
const resolveBase = () => {
return (envConfig.siteOrigin || envConfig.reportBaseUrl || '').replace(/\/$/, '')
}
const buildReportUrl = (page: 'example' | 'report', params: Record<string, string> = {}) => {
const token = getToken() || ''
const base = resolveBase()
if (!base) {
console.warn('[report webview] 请配置 VITE_SITE_ORIGIN 或 VITE_REPORT_BASE_URL')
return ''
}
const merged: Record<string, string> = { source: 'app', ...params }
if (token)
merged.token = token
const search = buildQueryString(merged)
/** 使用 H5 无顶栏的 App 专用路由,避免 PageLayout 与 App 原生导航栏重复 */
return `${base}/app/${page === 'example' ? 'example' : 'report'}?${search}`
}
const buildReportShareUrl = (linkIdentifier: string, params: Record<string, string> = {}) => {
const token = getToken() || ''
const base = resolveBase()
if (!base) {
console.warn('[report webview] 请配置 VITE_SITE_ORIGIN 或 VITE_REPORT_BASE_URL')
return ''
}
const merged: Record<string, string> = { source: 'app', ...params }
if (token)
merged.token = token
const search = buildQueryString(merged)
const encodedLink = encodeURIComponent(linkIdentifier)
return `${base}/report/share/${encodedLink}${search ? `?${search}` : ''}`
}
const buildSitePathUrl = (path: string, params: Record<string, string> = {}) => {
const token = getToken() || ''
const base = resolveBase()
if (!base) {
console.warn('[site webview] 请配置 VITE_SITE_ORIGIN 或 VITE_REPORT_BASE_URL')
return ''
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`
const merged: Record<string, string> = { source: 'app', ...params }
if (token)
merged.token = token
const search = buildQueryString(merged)
return `${base}${normalizedPath}${search ? `?${search}` : ''}`
}
const postMessageToWebview = (payload: Record<string, unknown>) => {
// #ifdef APP-PLUS
const current = getCurrentPages().at(-1) as any
const webview = current?.$getAppWebview?.()?.children?.()[0]
if (webview) {
webview.evalJS(`window.postMessage(${JSON.stringify(payload)}, '*')`)
}
// #endif
}
return { buildReportUrl, buildReportShareUrl, buildSitePathUrl, postMessageToWebview }
}

View File

@@ -0,0 +1,18 @@
import { watch } from 'vue'
/**
* 风险评分通知 composable
* 用于组件向父组件通知自己的风险评分0-100分分数越高越安全
*/
export function useRiskNotifier(props, riskScore) {
// 监听 riskScore 变化,通知父组件
watch(
riskScore,
(newValue) => {
if (props.apiId && props.notifyRiskStatus) {
props.notifyRiskStatus(props.apiId, props.index, newValue)
}
},
{ immediate: true },
)
}

View File

@@ -0,0 +1,6 @@
/** App 端不做 H5 SEO与 webview 接口兼容 */
export function useSEO() {
return {
updateSEO() {},
}
}

View File

@@ -0,0 +1,138 @@
import { onMounted, ref } from "vue";
import "@/static/uni-webview.js";
import { getToken, setToken } from '@/utils/storage'
const WEBVIEW_PLATFORM_KEY = 'webview_platform'
export function useWebView() {
const platform = ref("");
const token = ref("");
// 检测环境并通知父窗口加载完毕
const handleBridgeReady = () => {
if (platform.value) {
h5PostMessage("loaded", true);
}
};
// 获取 Token从 URL 中解析)
const getTokenFromUrl = () => {
const urlParams = new URLSearchParams(window.location.search);
const tokenFromUrl = urlParams.get("token");
token.value = tokenFromUrl || ""; // 如果 URL 没有 token返回空字符串
if (token.value) {
setToken(token.value);
} else {
token.value = getToken() || "";
}
return tokenFromUrl;
};
// 封装 postMessage 方法
const postMessage = (data) => {
if (platform.value === "h5") {
h5PostMessage("postMessage", data);
} else if (uni && uni.webView.postMessage) {
uni.webView.postMessage(data);
} else {
console.error("uni.webView.postMessage is not available.");
}
};
const redirectTo = (data) => {
if (platform.value === "h5") {
h5PostMessage("redirectTo", data);
} else if (uni && uni.webView.redirectTo) {
// 非 H5 环境,调用 uni.webView.redirectTo
uni.webView.redirectTo(data);
} else {
console.error("uni.webView.redirectTo is not available.");
}
};
// 封装 navigateBack 方法
const navigateBack = (data) => {
if (platform.value === "h5") {
window.top.history.back();
// h5PostMessage("navigateBack", data)
} else if (uni && uni.webView.navigateBack) {
// 非 H5 环境,调用 uni.webView.navigateBack
uni.webView.navigateBack(data);
} else {
console.error("uni.webView.navigateBack is not available.");
}
};
// 封装 navigateTo 方法
const navigateTo = (data) => {
if (platform.value === "h5") {
// h5PostMessage("navigateTo", data)
window.top.location.href = `/app${data.url}`;
} else if (uni && uni.webView.navigateTo) {
uni.webView.navigateTo(data);
} else {
console.error("uni.webView.navigateTo is not available.");
}
};
const payment = (data) => {
if (platform.value === "h5") {
h5PostMessage("payment", data);
} else if (uni && uni.webView.navigateTo) {
// 非 H5 环境,调用 uni.webView.navigateTo
uni.webView.navigateTo(data);
} else {
console.error("uni.webView.navigateTo is not available.");
}
};
const getEnv = () => {
return new Promise((resolve, reject) => {
const env = uni.getStorageSync(WEBVIEW_PLATFORM_KEY);
if (env) {
platform.value = env;
resolve(env);
} else {
uni.webView.getEnv((env) => {
// 遍历 env 对象,找到值为 true 的键
const platformKey = Object.keys(env).find((key) => env[key] === true);
platform.value = platformKey;
if (platformKey) {
uni.setStorageSync(WEBVIEW_PLATFORM_KEY, platformKey);
resolve(platformKey); // 返回键名(如 'h5', 'mp-weixin' 等)
} else {
reject("未知平台");
}
});
}
});
};
onMounted(async () => {
try {
const envValue = await getEnv();
console.log("当前环境", envValue);
// 将返回的键名(如 'h5', 'mp-weixin')存储到 platform
handleBridgeReady();
} catch (error) {
console.error(error);
}
// 获取 Token
getTokenFromUrl();
});
return {
platform,
token,
getEnv,
redirectTo,
postMessage,
navigateTo,
navigateBack,
payment,
};
}
function h5PostMessage(action, data) {
window.parent.postMessage(
{ action, data, messageId: generateUniqueId(action) },
"*",
);
}
const generateUniqueId = (action) => `msg_${action}_${new Date().getTime()}`;

View File

@@ -0,0 +1,8 @@
/**
* App 端不启用微信 JSSDK 分享;占位以兼容 QRcode 等迁移组件。
*/
export function useWeixinShare() {
return {
configWeixinShare() {},
}
}

View File

@@ -0,0 +1,34 @@
import { onMounted, ref } from 'vue'
import zoomAdapter from '../utils/zoomAdapter.js'
/**
* 简化版缩放适配组合式函数
*/
export function useZoomAdapter() {
const currentZoom = ref(1)
const isTooHighZoom = ref(false)
const handleZoomChange = (event) => {
const { zoom } = event.detail
currentZoom.value = zoom
isTooHighZoom.value = zoom > 3
}
onMounted(() => {
if (typeof window === 'undefined')
return
if (!zoomAdapter.isInitialized) {
zoomAdapter.init()
}
window.addEventListener('zoomChanged', handleZoomChange)
})
return {
currentZoom,
isTooHighZoom,
getZoomAdaptiveClass: () => ({
'zoom-adaptive': true,
'too-high-zoom': isTooHighZoom.value,
}),
}
}

23
src/constants/env.ts Normal file
View File

@@ -0,0 +1,23 @@
/** 完整接口根 = VITE_API_URL + VITE_API_PREFIX */
function resolveApiBase(): string {
const url = (import.meta.env.VITE_API_URL as string) || ''
const prefix = (import.meta.env.VITE_API_PREFIX as string) || ''
if (url && prefix)
return `${url.replace(/\/$/, '')}${prefix.startsWith('/') ? prefix : `/${prefix}`}`
return prefix
}
/** App 端实际通过 envConfig 消费的变量(其余页面直接用 import.meta.env */
export const envConfig = {
apiBaseUrl: resolveApiBase(),
siteOrigin: (import.meta.env.VITE_SITE_ORIGIN as string) || '',
reportBaseUrl: (import.meta.env.VITE_REPORT_BASE_URL as string) || '',
customerServiceUrl: (import.meta.env.VITE_CUSTOMER_SERVICE_URL as string) || '',
wxworkCorpId: (import.meta.env.VITE_WXWORK_CORP_ID as string) || '',
customerServiceSkipSdk: ['1', 'true', 'yes'].includes(
String((import.meta.env.VITE_CUSTOMER_SERVICE_SKIP_SDK as string) || '').toLowerCase(),
),
}
if (!envConfig.apiBaseUrl)
console.warn('[bdrp-app] 缺少 VITE_API_PREFIX')

View File

@@ -0,0 +1,44 @@
export type MigrationMode = 'native' | 'webview'
export interface RouteMigrationItem {
webviewPath: string
appPath: string
mode: MigrationMode
feature: string
}
export const routeMigrationMap: RouteMigrationItem[] = [
{ webviewPath: '/', appPath: '/pages/index', mode: 'native', feature: '首页' },
{ webviewPath: '/login', appPath: '/pages/login', mode: 'native', feature: '登录' },
{ webviewPath: '/inquire/:feature', appPath: '/pages/inquire', mode: 'native', feature: '查询下单' },
{ webviewPath: '/historyQuery', appPath: '/pages/history-query', mode: 'native', feature: '历史报告' },
{ webviewPath: '/payment/result', appPath: '/pages/payment-result', mode: 'native', feature: '支付结果' },
{ webviewPath: '/agent', appPath: '/pages/agent', mode: 'native', feature: '代理中心' },
{ webviewPath: '/agent/promote', appPath: '/pages/promote', mode: 'native', feature: '推广管理' },
{ webviewPath: '/agent/promoteDetails', appPath: '/pages/agent-promote-details', mode: 'native', feature: '直推收益明细' },
{ webviewPath: '/agent/rewardsDetails', appPath: '/pages/agent-rewards-details', mode: 'native', feature: '奖励收益明细' },
{ webviewPath: '/agent/invitation', appPath: '/pages/invitation', mode: 'native', feature: '邀请下级' },
{ webviewPath: '/agent/vipApply', appPath: '/pages/agent-vip-apply', mode: 'native', feature: 'VIP申请' },
{ webviewPath: '/agent/vipConfig', appPath: '/pages/agent-vip-config', mode: 'native', feature: 'VIP配置' },
{ webviewPath: '/withdraw', appPath: '/pages/withdraw', mode: 'native', feature: '提现' },
{ webviewPath: '/agent/withdrawDetails', appPath: '/pages/withdraw-details', mode: 'native', feature: '提现记录' },
{ webviewPath: '/agent/subordinateList', appPath: '/pages/subordinate-list', mode: 'native', feature: '我的下级' },
{ webviewPath: '/agent/subordinateDetail/:id', appPath: '/pages/subordinate-detail', mode: 'native', feature: '下级详情' },
{ webviewPath: '/agent/invitationAgentApply/:linkIdentifier', appPath: '/pages/invitation-agent-apply', mode: 'native', feature: '代理申请' },
{ webviewPath: '/help', appPath: '/pages/help', mode: 'native', feature: '帮助中心' },
{ webviewPath: '/help/detail', appPath: '/pages/help-detail', mode: 'native', feature: '帮助详情' },
{ webviewPath: '/help/guide', appPath: '/pages/help-guide', mode: 'native', feature: '引导指南' },
{ webviewPath: '/authorization', appPath: '/pages/authorization', mode: 'webview', feature: '授权书' },
{ webviewPath: '/privacyPolicy', appPath: '/pages/privacy-policy', mode: 'webview', feature: '隐私政策' },
{ webviewPath: '/userAgreement', appPath: '/pages/user-agreement', mode: 'webview', feature: '用户协议' },
{ webviewPath: '/agentManageAgreement', appPath: '/pages/agent-manage-agreement', mode: 'webview', feature: '代理管理协议' },
{ webviewPath: '/agentSerivceAgreement', appPath: '/pages/agent-service-agreement', mode: 'webview', feature: '信息技术服务合同' },
{ webviewPath: '/me', appPath: '/pages/me', mode: 'native', feature: '我的' },
{ webviewPath: '/cancelAccount', appPath: '/pages/cancel-account', mode: 'native', feature: '注销账号' },
{ webviewPath: '/report/share/:linkIdentifier', appPath: '/pages/report-share', mode: 'webview', feature: '报告分享' },
{ webviewPath: '/agent/promotionInquire/:linkIdentifier', appPath: '/pages/promotion-inquire', mode: 'native', feature: '推广查询' },
{ webviewPath: '/:pathMatch(.*)*', appPath: '/pages/not-found', mode: 'native', feature: '404' },
{ webviewPath: '/maintenance', appPath: '/pages/maintenance', mode: 'native', feature: '维护页' },
{ webviewPath: '/app/example', appPath: '/pages/report-example-webview', mode: 'webview', feature: '示例报告' },
{ webviewPath: '/app/report', appPath: '/pages/report-result-webview', mode: 'webview', feature: '结果报告' },
]

File diff suppressed because it is too large Load Diff

1
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

195
src/layouts/default.vue Normal file
View File

@@ -0,0 +1,195 @@
<script setup>
import { onShow } from '@dcloudio/uni-app'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
getCurrentUniRoute,
getLayoutPageTitle,
getWebPathForNotification,
useRouter,
} from '@/composables/uni-router'
import useApiFetch from '@/composables/useApiFetch'
import { ensurePageAccessByUrl } from '@/composables/useNavigationAuthGuard'
const router = useRouter()
const showPopup = ref(false)
const notify = ref([])
const currentNotify = ref(null)
const pageTitle = ref('赤眉')
const currentRoute = ref('')
const safeAreaTop = ref(0)
const immersiveNavbarSolid = ref(false)
const immersiveRoutes = new Set([
'pages/inquire',
])
const isImmersivePage = computed(() => immersiveRoutes.has(currentRoute.value))
const immersiveNavbarClass = computed(() => (
immersiveNavbarSolid.value ? 'immersive-wd-navbar is-solid' : 'immersive-wd-navbar'
))
const immersiveNavbarStyle = computed(() => (
immersiveNavbarSolid.value
? 'background: rgba(255,255,255,0.96); box-shadow: 0 2px 12px rgba(15, 23, 42, 0.08);'
: 'background: transparent; box-shadow: none;'
))
function syncTitle() {
currentRoute.value = getCurrentUniRoute()
pageTitle.value = getLayoutPageTitle()
immersiveNavbarSolid.value = false
}
function buildCurrentPageUrl() {
const pages = getCurrentPages()
const page = pages[pages.length - 1] || {}
const route = `/${(page?.route || 'pages/index').replace(/^\//, '')}`
const query = page?.options || {}
const queryString = Object.keys(query).length
? `?${Object.entries(query).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value || ''))}`).join('&')}`
: ''
return `${route}${queryString}`
}
function ensureAuthIfNeeded() {
ensurePageAccessByUrl(buildCurrentPageUrl(), 'redirect')
}
function getSafeAreaTop() {
uni.getSystemInfo({
success: (res) => {
if (res.safeArea)
safeAreaTop.value = res.safeArea.top
},
})
}
onMounted(() => {
getSafeAreaTop()
syncTitle()
getGlobalNotify()
})
onShow(() => {
syncTitle()
ensureAuthIfNeeded()
checkNotification()
})
async function getGlobalNotify() {
const { data, error } = await useApiFetch('/notification/list').get().json()
if (!data.value || error.value)
return
if (data.value.code !== 200)
return
const raw = data.value.data
const list = raw && typeof raw === 'object' && 'notifications' in raw
? raw.notifications
: []
notify.value = Array.isArray(list) ? list : []
checkNotification()
}
function isWithinTimeRange(startTime, endTime) {
const now = new Date()
const currentMinutes = now.getHours() * 60 + now.getMinutes()
const startParts = startTime.split(':').map(Number)
const endParts = endTime.split(':').map(Number)
const startMinutes = startParts[0] * 60 + startParts[1]
const endMinutes = endParts[0] * 60 + endParts[1]
if (endMinutes < startMinutes)
return currentMinutes >= startMinutes || currentMinutes < endMinutes
return currentMinutes >= startMinutes && currentMinutes <= endMinutes
}
function checkNotification() {
const webPath = getWebPathForNotification()
showPopup.value = false
for (const notification of notify.value) {
const isTimeValid = isWithinTimeRange(notification.startTime, notification.endTime)
if (isTimeValid && notification.notificationPage === webPath) {
currentNotify.value = notification
showPopup.value = true
break
}
}
}
function onClickLeft() {
router.back()
}
function onImmersiveNavbarChange(payload) {
if (!payload || payload.route !== currentRoute.value || !immersiveRoutes.has(payload.route))
return
immersiveNavbarSolid.value = Boolean(payload.solid)
}
onMounted(() => {
uni.$on('immersive-navbar-change', onImmersiveNavbarChange)
})
onUnmounted(() => {
uni.$off('immersive-navbar-change', onImmersiveNavbarChange)
})
</script>
<template>
<view
class="page-layout min-h-screen from-sky-100/20 to-white bg-gradient-to-b"
:class="{ 'transparent-layout': isImmersivePage }"
>
<wd-navbar
v-if="isImmersivePage"
:class="immersiveNavbarClass"
:title="pageTitle"
:custom-style="immersiveNavbarStyle"
safe-area-inset-top
left-arrow
fixed
@click-left="onClickLeft"
/>
<wd-navbar v-else :title="pageTitle" safe-area-inset-top left-arrow placeholder fixed @click-left="onClickLeft" />
<slot />
<wd-popup v-model="showPopup" round>
<view class="popup-content p-8 text-center">
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="notify-html" v-html="currentNotify?.content" />
<view class="flex justify-center">
<wd-button type="primary" class="w-24" @click="showPopup = false">
关闭
</wd-button>
</view>
</view>
</wd-popup>
</view>
</template>
<style lang="scss" scoped>
.transparent-layout {
background: transparent;
}
:deep(.immersive-wd-navbar) {
z-index: 1000;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
:deep(.immersive-wd-navbar .wd-navbar__title),
:deep(.immersive-wd-navbar .wd-navbar__left),
:deep(.immersive-wd-navbar .wd-navbar__left .wd-icon) {
color: #fff !important;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.22);
transition: color 0.2s ease, text-shadow 0.2s ease;
}
:deep(.immersive-wd-navbar.is-solid .wd-navbar__title),
:deep(.immersive-wd-navbar.is-solid .wd-navbar__left),
:deep(.immersive-wd-navbar.is-solid .wd-navbar__left .wd-icon) {
color: #111827 !important;
text-shadow: none;
}
.popup-content {
max-width: 90vw;
}
</style>

176
src/layouts/home.vue Normal file
View File

@@ -0,0 +1,176 @@
<script setup>
import { onShow } from '@dcloudio/uni-app'
import { onMounted, reactive, ref } from 'vue'
import { getCurrentUniRoute } from '@/composables/uni-router'
import { openCustomerService } from '@/composables/useCustomerService'
import { ensurePageAccessByUrl } from '@/composables/useNavigationAuthGuard'
const tabbar = ref('index')
const safeAreaTop = ref(0)
const menu = reactive([
{ title: '首页', icon: 'home', name: 'index' },
{ title: '资产', icon: 'cart', name: 'agent' },
{ title: '我的', icon: 'user', name: 'me' },
])
const COMPANY_NAME = import.meta.env.VITE_COMPANY_NAME
if (!COMPANY_NAME)
throw new Error('缺少环境变量: VITE_COMPANY_NAME')
const tabRouteMap = {
'pages/index': 'index',
'pages/agent': 'agent',
'pages/me': 'me',
}
function syncTabbar() {
const r = getCurrentUniRoute()
if (r === 'pages/promote')
tabbar.value = 'agent'
else
tabbar.value = tabRouteMap[r] || 'index'
}
function getSafeAreaTop() {
uni.getSystemInfo({
success: (res) => {
if (res.safeArea) {
safeAreaTop.value = res.safeArea.top
}
},
})
}
onMounted(getSafeAreaTop)
onMounted(syncTabbar)
function buildCurrentPageUrl() {
const pages = getCurrentPages()
const page = pages[pages.length - 1] || {}
const route = `/${(page?.route || 'pages/index').replace(/^\//, '')}`
const query = page?.options || {}
const queryString = Object.keys(query).length
? `?${Object.entries(query).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value || ''))}`).join('&')}`
: ''
return `${route}${queryString}`
}
function ensureAuthIfNeeded() {
ensurePageAccessByUrl(buildCurrentPageUrl(), 'redirect')
}
onShow(() => {
syncTabbar()
ensureAuthIfNeeded()
})
function tabChange(payload) {
const name = typeof payload === 'string' ? payload : payload?.value
const urlMap = {
index: '/pages/index',
agent: '/pages/agent',
me: '/pages/me',
}
const url = urlMap[name]
if (url)
uni.reLaunch({ url })
}
function toComplaint() {
openCustomerService()
}
</script>
<template>
<view class="home-layout min-h-screen flex flex-col">
<view :style="{ paddingTop: `${safeAreaTop}px` }">
<view class="header">
<image
class="logo overflow-hidden rounded-full"
src="/static/images/homelayout/title_logo.png"
mode="aspectFit"
/>
</view>
</view>
<view class="content flex flex-1 flex-col">
<slot />
</view>
<wd-tabbar
v-model="tabbar"
fixed
bordered
safe-area-inset-bottom
placeholder
active-color="#1d4ed8"
inactive-color="#888888"
@change="tabChange"
>
<wd-tabbar-item
v-for="(item, index) in menu"
:key="index"
:name="item.name"
:title="item.title"
:icon="item.icon"
/>
</wd-tabbar>
<view class="complaint-button" @click="toComplaint">
<image src="/static/images/homelayout/ts.png" mode="aspectFit" class="complaint-icon" />
<text>投诉</text>
</view>
</view>
</template>
<style scoped>
.home-layout {
@apply from-sky-100/20 to-white bg-gradient-to-b
}
.header {
display: flex;
align-items: center;
background-color: white;
padding-left: 1rem;
padding-right: 1rem;
height: 3rem;
}
.logo {
height: 2rem;
margin-right: 0.5rem;
width: 8rem;
object-fit: contain;
}
.complaint-button {
position: fixed;
bottom: 6rem;
right: 1rem;
background: #ff6b6b;
border-radius: 1.5rem;
padding: 0.25rem 1rem;
color: white;
display: flex;
align-items: center;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
z-index: 2000;
}
.complaint-icon {
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
}
.disclaimer {
padding: 10px;
font-size: 12px;
color: #999;
text-align: center;
border-top: 1px solid #e0e0e0;
padding-bottom: 60px;
background: #ffffff;
margin-bottom: 50px;
}
</style>

15
src/main.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createPinia } from 'pinia'
import { createSSRApp } from 'vue'
import App from './App.vue'
import './static/main.css'
import 'uno.css'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return {
app,
pinia,
}
}

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad(() => {
src.value = buildSitePathUrl('/app/agentManageAgreement')
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,239 @@
<script setup>
definePage({ layout: 'default', auth: true })
// 颜色配置(根据产品名称映射)
const typeColors = {
小微企业: { bg: 'bg-blue-100', text: 'text-blue-800', dot: 'bg-blue-500' },
入职风险: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
家政风险: { bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500' },
婚恋风险: { bg: 'bg-pink-100', text: 'text-pink-800', dot: 'bg-pink-500' },
贷前风险: { bg: 'bg-orange-100', text: 'text-orange-800', dot: 'bg-orange-500' },
租赁风险: { bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500' },
个人风险: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' },
个人大数据: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500' },
// 默认类型
default: { bg: 'bg-gray-100', text: 'text-gray-800', dot: 'bg-gray-500' },
}
// 新增点 4: 定义脱敏函数
function desen(value, type) {
// 如果值是空,直接返回空字符串,让外层的 || '-' 生效
if (!value) {
return ''
}
// 根据类型进行不同的替换
switch (type) {
case 'mobile': // 手机号保留前3位和后4位中间4位替换为 ****
return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2')
case 'id_card':// 身份证保留前6位和后1位中间替换为 ********
if (value.length <= 7)
return value
return `${value.slice(0, 6)}********${value.slice(-1)}`
default:
return value // 其他类型不处理
}
}
const page = ref(1)
const pageSize = ref(10)
const data = ref({
total: 0,
list: [],
})
const loading = ref(false)
// 获取颜色样式
function getReportTypeStyle(name) {
const color = typeColors[name] || typeColors.default
return `${color.bg} ${color.text}`
}
// 获取小圆点颜色
function getDotColor(name) {
return (typeColors[name] || typeColors.default).dot
}
// 获取金额颜色
function getAmountColor(item) {
// 如果净佣金为0或状态为已退款显示红色
if (item.net_amount <= 0 || item.status === 2) {
return 'text-red-500'
}
// 如果有部分退款,显示橙色
if (item.refunded_amount > 0) {
return 'text-orange-500'
}
// 正常情况显示绿色
return 'text-green-500'
}
// 获取金额前缀(+ 或 -
function getAmountPrefix(item) {
if (item.net_amount <= 0 || item.status === 2) {
return '-'
}
return '+'
}
// 获取状态文本
function getStatusText(item) {
if (item.status === 2 || item.net_amount <= 0) {
return '已退款'
}
if (item.status === 1) {
// 冻结中
if (item.refunded_amount > 0) {
return '冻结中(部分退款)'
}
return '冻结中'
}
if (item.status === 0) {
// 已结算
if (item.refunded_amount > 0) {
return '已结算(部分退款)'
}
return '已结算'
}
return '未知状态'
}
// 获取状态样式
function getStatusStyle(item) {
if (item.status === 2 || item.net_amount <= 0) {
return 'bg-red-100 text-red-800'
}
if (item.status === 1) {
// 冻结中
if (item.refunded_amount > 0) {
return 'bg-orange-100 text-orange-800'
}
return 'bg-yellow-100 text-yellow-800'
}
if (item.status === 0) {
// 已结算
if (item.refunded_amount > 0) {
return 'bg-blue-100 text-blue-800'
}
return 'bg-green-100 text-green-800'
}
return 'bg-gray-100 text-gray-800'
}
// 获取数据
async function getData() {
try {
loading.value = true
const { data: res, error } = await useApiFetch(
`/agent/commission?page=${page.value}&page_size=${pageSize.value}`,
).get().json()
if (res.value?.code === 200 && !error.value) {
data.value = res.value.data
}
}
finally {
loading.value = false
}
}
function onPageChange({ value }) {
page.value = value
getData()
}
// 初始化加载
onMounted(() => {
getData()
})
</script>
<template>
<view class="min-h-screen bg-gray-50">
<view class="detail-scroll">
<view v-for="(item, index) in data.list" :key="index" class="mx-4 my-2 rounded-lg bg-white p-4 shadow-sm">
<view class="mb-2 flex items-center justify-between">
<!-- 修改点 1: 使用 desen 函数处理 mobile -->
<text class="text-sm text-gray-500">
{{ desen(item.query_params?.mobile, 'mobile') || '-' }}
</text>
<text
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
:class="getReportTypeStyle(item.product_name)"
>
<text class="mr-1 h-2 w-2 rounded-full" :class="getDotColor(item.product_name)" />
{{ item.product_name }}
</text>
</view>
<view class="mb-2 flex items-center justify-between">
<text class="text-gray-700 font-medium">
直接收益
</text>
<view class="flex flex-col items-end">
<!-- 主金额显示净佣金 -->
<text :class="getAmountColor(item)" class="text-lg font-bold">
{{ getAmountPrefix(item) }}{{ (item.net_amount || 0).toFixed(2) }}
</text>
<!-- 如果有部分退款显示原始金额和已退金额 -->
<text v-if="item.refunded_amount > 0 && item.net_amount > 0" class="mt-1 text-xs text-gray-400">
原始 {{ item.amount.toFixed(2) }}已退 {{ item.refunded_amount.toFixed(2) }}
</text>
</view>
</view>
<view class="flex items-center justify-between">
<text class="text-sm text-gray-500">
{{ desen(item.query_params?.name) || '-' }}
</text>
<!-- 状态标签 -->
<text
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="getStatusStyle(item)"
>
{{ getStatusText(item) }}
</text>
</view>
<view class="mb-2 flex items-center justify-between">
<text class="text-sm text-gray-500">
{{ item.create_time || '-' }}
</text>
</view>
<view class="mt-2 flex items-center">
<text class="text-sm text-gray-500">
订单号
</text>
<text class="text-sm text-gray-700 font-mono">
{{ item.order_id || '-' }}
</text>
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!data.list.length" class="py-4 text-center text-sm text-gray-400">
暂无记录
</view>
</view>
<wd-pagination
v-model="page"
:total="data.total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</view>
</template>
<style scoped>
/* 列表项入场动画 */
.list-enter-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.detail-scroll {
height: calc(100vh - 110px);
}
</style>

View File

@@ -0,0 +1,133 @@
<script setup>
definePage({ layout: 'default', auth: true })
// 类型映射配置
const typeConfig = {
descendant_promotion: {
chinese: '下级推广奖励',
color: { bg: 'bg-blue-100', text: 'text-blue-800', dot: 'bg-blue-500' },
},
descendant_upgrade_vip: {
chinese: '下级升级VIP奖励',
color: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500' },
},
descendant_upgrade_svip: {
chinese: '下级升级SVIP奖励',
color: { bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500' },
},
descendant_withdraw: {
chinese: '下级提现奖励',
color: { bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500' },
},
default: {
chinese: '其他奖励',
color: { bg: 'bg-gray-100', text: 'text-gray-800', dot: 'bg-gray-500' },
},
}
const page = ref(1)
const pageSize = ref(10)
const data = ref({
total: 0,
list: [],
})
const loading = ref(false)
// 类型转中文
function typeToChinese(type) {
return typeConfig[type]?.chinese || typeConfig.default.chinese
}
// 获取颜色样式
function getReportTypeStyle(type) {
const config = typeConfig[type] || typeConfig.default
return `${config.color.bg} ${config.color.text}`
}
// 获取小圆点颜色
function getDotColor(type) {
return typeConfig[type]?.color.dot || typeConfig.default.color.dot
}
// 获取数据
async function getData() {
try {
loading.value = true
const { data: res, error } = await useApiFetch(
`/agent/rewards?page=${page.value}&page_size=${pageSize.value}`,
).get().json()
if (res.value?.code === 200 && !error.value) {
data.value = res.value.data
}
}
finally {
loading.value = false
}
}
function onPageChange({ value }) {
page.value = value
getData()
}
// 初始化加载
onMounted(() => {
getData()
})
</script>
<template>
<view class="min-h-screen bg-gray-50">
<view class="reward-scroll">
<view v-for="(item, index) in data.list" :key="index" class="mx-4 my-2 rounded-lg bg-white p-4 shadow-sm">
<view class="mb-2 flex items-center justify-between">
<text class="text-sm text-gray-500">
{{ item.create_time || '-' }}
</text>
<text class="text-green-500 font-bold">
+{{ item.amount.toFixed(2) }}
</text>
</view>
<view class="flex items-center">
<text
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
:class="getReportTypeStyle(item.type)"
>
<text class="mr-1 h-2 w-2 rounded-full" :class="getDotColor(item.type)" />
{{ typeToChinese(item.type) }}
</text>
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!data.list.length" class="py-4 text-center text-sm text-gray-400">
暂无记录
</view>
</view>
<wd-pagination
v-model="page"
:total="data.total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</view>
</template>
<style scoped>
/* 保持原有样式不变 */
.list-enter-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.reward-scroll {
height: calc(100vh - 110px);
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad(() => {
src.value = buildSitePathUrl('/app/agentSerivceAgreement')
})
</script>
<template>
<web-view :src="src" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
definePage({ layout: 'default', auth: true })
// 报告类型选项
const reportOptions = [
{ text: '小微企业', value: 'companyinfo', id: 2 },
{ text: '贷前风险', value: 'preloanbackgroundcheck', id: 5 },
{ text: '个人大数据', value: 'personaldata', id: 27 },
{ text: '入职风险', value: 'backgroundcheck', id: 1 },
{ text: '家政风险', value: 'homeservice', id: 3 },
{ text: '婚恋风险', value: 'marriage', id: 4 },
// { text: "租赁风险", value: "rentalrisk", id: 6 },
// { text: "个人风险", value: "riskassessment", id: 7 },
]
// 状态管理
const showPicker = ref(false)
const selectedReport = ref(reportOptions[0])
const selectedReportText = ref(reportOptions[0].text)
const selectedReportId = ref(reportOptions[0].id)
const configData = ref({})
const productConfigData = ref({})
const priceIncreaseMax = ref(null)
const priceIncreaseAmountMax = ref(null)
const priceRatioMax = ref(null)
const rangeError = ref(false)
const ratioError = ref(false)
const increaseError = ref(false)
function showToast(message) {
if (!message)
return
uni.showToast({
title: message,
icon: 'none',
})
}
// 金额输入格式验证:确保最多两位小数
function validateDecimal(field) {
const value = configData.value[field]
if (value === null || value === undefined)
return
const numValue = Number(value)
if (isNaN(numValue)) {
configData.value[field] = null
return
}
const fixedValue = Number.parseFloat(numValue.toFixed(2))
configData.value[field] = fixedValue
if (field === 'price_increase_amount') {
if (fixedValue > priceIncreaseAmountMax.value) {
configData.value[field] = priceIncreaseAmountMax.value
showToast(`加价金额最大为${priceIncreaseAmountMax.value}`)
increaseError.value = true
setTimeout(() => {
increaseError.value = false
}, 2000)
}
else {
increaseError.value = false
}
// 当加价金额改变后,重新验证价格区间
validateRange()
}
}
// 价格区间验证(在 @blur 中调用)
function validateRange() {
console.log(
'configData.value.price_range_from',
configData.value.price_range_from,
)
console.log(
'configData.value.price_range_to',
configData.value.price_range_to,
)
if (
configData.value.price_range_from === null
|| configData.value.price_range_to === null
) {
rangeError.value = false
return
}
if (
isNaN(configData.value.price_range_from)
|| isNaN(configData.value.price_range_to)
) {
return
}
const additional = configData.value.price_increase_amount || 0
const minAllowed = Number.parseFloat(
(
Number(productConfigData.value.cost_price) + Number(additional)
).toFixed(2),
) // 使用成本价作为最小值
const maxAllowed = productConfigData.value.price_range_max // 使用产品配置中的最大价格作为最大值
if (configData.value.price_range_from < minAllowed) {
configData.value.price_range_from = minAllowed
showToast(`最低金额不能低于成本价 ${minAllowed}`)
rangeError.value = true
closeRangeError()
configData.value.price_range_to = Number.parseFloat(
(
Number(configData.value.price_range_from)
+ Number(priceIncreaseMax.value)
).toFixed(2),
)
return
}
if (configData.value.price_range_to < configData.value.price_range_from) {
showToast('最高金额不能低于最低金额')
if (
configData.value.price_range_from + priceIncreaseMax.value
> maxAllowed
) {
configData.value.price_range_to = maxAllowed
}
else {
configData.value.price_range_to
= configData.value.price_range_from + priceIncreaseMax.value
}
rangeError.value = true
closeRangeError()
return
}
const diff = Number.parseFloat(
(
configData.value.price_range_to - configData.value.price_range_from
).toFixed(2),
)
if (diff > priceIncreaseMax.value) {
showToast(`价格区间最大差值为${priceIncreaseMax.value}`)
configData.value.price_range_to
= configData.value.price_range_from + priceIncreaseMax.value
closeRangeError()
return
}
if (configData.value.price_range_to > maxAllowed) {
configData.value.price_range_to = maxAllowed
showToast(`最高金额不能超过 ${maxAllowed}`)
closeRangeError()
}
if (!rangeError.value) {
rangeError.value = false
}
}
// 收取比例验证(修改为保留两位小数,不再四舍五入取整)
function validateRatio() {
const value = configData.value.price_ratio
if (value === null || value === undefined)
return
const numValue = Number(value)
if (isNaN(numValue)) {
configData.value.price_ratio = null
ratioError.value = true
return
}
if (numValue > priceRatioMax.value) {
configData.value.price_ratio = priceRatioMax.value
showToast(`收取比例最大为${priceRatioMax.value}%`)
ratioError.value = true
setTimeout(() => {
ratioError.value = false
}, 1000)
}
else if (numValue < 0) {
configData.value.price_ratio = 0
ratioError.value = true
}
else {
configData.value.price_ratio = Number.parseFloat(numValue.toFixed(2))
ratioError.value = false
}
}
// 获取配置
async function getConfig() {
try {
const { data, error } = await useApiFetch(
`/agent/membership/user_config?product_id=${selectedReportId.value}`,
)
.get()
.json()
if (data.value?.code === 200) {
const respConfigData = data.value.data.agent_membership_user_config
configData.value = {
id: respConfigData.product_id,
price_range_from: respConfigData.price_range_from || null,
price_range_to: respConfigData.price_range_to || null,
price_ratio: respConfigData.price_ratio * 100 || null, // 转换为百分比
price_increase_amount:
respConfigData.price_increase_amount || null,
}
console.log('configData', configData.value)
// const respProductConfigData = data.value.data.product_config
productConfigData.value = data.value.data.product_config
// 设置动态限制值
priceIncreaseMax.value = data.value.data.price_increase_max
priceIncreaseAmountMax.value
= data.value.data.price_increase_amount
priceRatioMax.value = data.value.data.price_ratio * 100
}
}
catch (error) {
showToast('配置加载失败')
}
}
// 提交处理
async function handleSubmit() {
try {
if (!finalValidation()) {
return
}
// 前端数据转换
const submitData = {
product_id: configData.value.id,
price_range_from: configData.value.price_range_from || 0,
price_range_to: configData.value.price_range_to || 0,
price_ratio: (configData.value.price_ratio || 0) / 100, // 转换为小数
price_increase_amount: configData.value.price_increase_amount || 0,
}
console.log('submitData', submitData)
const { data, error } = await useApiFetch(
'/agent/membership/save_user_config',
)
.post(submitData)
.json()
if (data.value?.code === 200) {
setTimeout(() => {
showToast('保存成功')
}, 500)
getConfig()
}
}
catch (error) {
showToast('保存失败,请稍后重试')
}
}
// 最终验证函数
function finalValidation() {
// 校验最低金额不能为空且大于0
if (
!configData.value.price_range_from
|| configData.value.price_range_from <= 0
) {
showToast('最低金额不能为空')
return false
}
// 校验最高金额不能为空且大于0
if (
!configData.value.price_range_to
|| configData.value.price_range_to <= 0
) {
showToast('最高金额不能为空')
return false
}
// 校验收取比例不能为空且大于0
if (!configData.value.price_ratio || configData.value.price_ratio <= 0) {
showToast('收取比例不能为空')
return false
}
// 验证最低金额必须小于最高金额
if (configData.value.price_range_from >= configData.value.price_range_to) {
showToast('最低金额必须小于最高金额')
return false
}
// 验证价格区间差值不能超过最大允许差值
const finalDiff = Number.parseFloat(
(
configData.value.price_range_to - configData.value.price_range_from
).toFixed(2),
)
if (finalDiff > priceIncreaseMax.value) {
showToast(`价格区间最大差值为${priceIncreaseMax.value}`)
return false
}
// 验证最高金额不能超过产品配置中设定的上限
if (
configData.value.price_range_to
> productConfigData.value.price_range_max
) {
showToast(
`最高金额不能超过${productConfigData.value.price_range_max}`,
)
return false
}
// 验证最低金额不能低于成本价+加价金额(加价金额允许为空)
const additional = configData.value.price_increase_amount || 0
if (
configData.value.price_range_from
< productConfigData.value.cost_price + additional
) {
showToast(
`最低金额不能低于成本价${productConfigData.value.cost_price + additional
}`,
)
return false
}
return true
}
// 选择器确认
function onSelectReport(option) {
selectedReport.value = option
selectedReportText.value = option.text
selectedReportId.value = option.id
showPicker.value = false
// 重置错误状态
rangeError.value = false
ratioError.value = false
increaseError.value = false
getConfig()
}
function closeRangeError() {
setTimeout(() => {
rangeError.value = false
}, 2000)
}
onMounted(() => {
getConfig()
})
</script>
<template>
<div class="mx-auto max-w-3xl min-h-screen p-4">
<!-- 标题部分 -->
<div class="card mb-4 rounded-lg from-blue-500 to-blue-600 bg-gradient-to-r p-4 text-white shadow-lg">
<h1 class="mb-2 text-2xl font-extrabold">
专业报告定价配置
</h1>
<p class="opacity-90">
请选择报告类型并设置定价策略助您实现精准定价
</p>
</div>
<div class="mb-4">
<view class="card selector" @click="showPicker = true">
<view class="selector-label">
📝 选择报告
</view>
<view class="selector-value">
{{ selectedReportText }}
</view>
</view>
<wd-popup v-model="showPicker" position="bottom" custom-style="padding: 16px;">
<view class="popup-title">
选择报告类型
</view>
<view class="report-list">
<view
v-for="item in reportOptions"
:key="item.value"
class="report-item"
:class="{ active: selectedReportId === item.id }"
@click="onSelectReport(item)"
>
{{ item.text }}
</view>
</view>
</wd-popup>
</div>
<div v-if="selectedReportText" class="space-y-6">
<!-- 配置卡片 -->
<div class="card">
<!-- 当前报告标题 -->
<div class="mb-6 flex items-center">
<h2 class="text-xl text-gray-800 font-semibold">
{{ selectedReportText }}配置
</h2>
</div>
<!-- 显示当前产品的基础成本信息 -->
<div
v-if="productConfigData && productConfigData.cost_price"
class="mb-4 border border-gray-200 rounded-lg bg-gray-50 px-4 py-2 shadow-sm"
>
<div class="text-lg text-gray-700 font-semibold">
报告基础配置信息
</div>
<div class="mt-1 text-sm text-gray-600">
<div>
基础成本价<span class="font-medium">{{
productConfigData.cost_price
}}</span>
</div>
<!-- <div>区间起始价<span class="font-medium">{{ productConfigData.price_range_min }}</span> </div> -->
<div>
最高设定金额上限<span class="font-medium">{{
productConfigData.price_range_max
}}</span>
</div>
<div>
最高设定比例上限<span class="font-medium">{{
priceRatioMax
}}</span>
%
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="section-divider my-6">
成本策略配置
</div>
<!-- 加价金额 -->
<view class="custom-field" :class="{ 'field-error': increaseError }">
<text class="field-label">
🚀 加价金额
</text>
<view class="field-input-wrap">
<input
v-model.number="configData.price_increase_amount"
type="number"
placeholder="0"
class="field-input"
@blur="validateDecimal('price_increase_amount')"
>
<text class="field-unit">
</text>
</view>
</view>
<div class="mt-1 text-xs text-gray-400">
提示最大加价金额为{{ priceIncreaseAmountMax }}<br>
说明加价金额是在基础成本价上增加的额外费用决定下级报告的最低定价您将获得所有输入的金额利润
</div>
<!-- 分隔线 -->
<div class="section-divider my-6">
定价策略配置
</div>
<!-- 定价区间最低 -->
<view class="custom-field" :class="{ 'field-error': rangeError }">
<text class="field-label">
💰 最低金额
</text>
<view class="field-input-wrap">
<input
v-model.number="configData.price_range_from"
type="number"
placeholder="0"
class="field-input"
@blur="
() => {
validateDecimal('price_range_from')
validateRange()
}
"
>
<text class="field-unit">
</text>
</view>
</view>
<div class="mt-1 text-xs text-gray-400">
提示最低金额不能低于基础最低
{{ productConfigData?.price_range_min || 0 }} +
加价金额<br>
说明设定的最低金额为定价区间的起始值若下级设定的报告金额在区间内则区间内部分将按比例获得收益
</div>
<!-- 定价区间最高 -->
<view class="custom-field" :class="{ 'field-error': rangeError }">
<text class="field-label">
💰 最高金额
</text>
<view class="field-input-wrap">
<input
v-model.number="configData.price_range_to"
type="number"
placeholder="0"
class="field-input"
@blur="
() => {
validateDecimal('price_range_to')
validateRange()
}
"
>
<text class="field-unit">
</text>
</view>
</view>
<div class="mt-1 text-xs text-gray-400">
提示最高金额不能超过上限{{
productConfigData?.price_range_max || 0
}}和大于最低金额{{ priceIncreaseMax }}<br>
说明设定的最高金额为定价区间的结束值若下级设定的报告金额在区间内则区间内部分将按比例获得收益
</div>
<!-- 收取比例 -->
<view class="custom-field" :class="{ 'field-error': ratioError }">
<text class="field-label">
📈 收取比例
</text>
<view class="field-input-wrap">
<input
v-model.number="configData.price_ratio"
type="number"
placeholder="0"
class="field-input"
@blur="validateRatio()"
>
<text class="field-unit">
%
</text>
</view>
</view>
<div class="mt-1 text-xs text-gray-400">
提示最大收取比例为{{ priceRatioMax }}%<br>
说明收取比例表示对定价区间内即报告金额超过最低金额小于最高金额的部分的金额按此比例进行利润分成
</div>
</div>
<!-- 保存按钮 -->
<wd-button
type="primary" block
class="h-12 rounded-xl from-blue-500 to-blue-600 bg-gradient-to-r text-white shadow-lg hover:from-blue-600 hover:to-blue-700"
@click="handleSubmit"
>
保存当前报告配置
</wd-button>
</div>
<!-- 未选择提示 -->
<div v-else class="py-12 text-center">
<text class="mb-4 block text-4xl text-gray-400">
</text>
<p class="text-gray-500">
请先选择需要配置的报告类型
</p>
</div>
</div>
</template>
<style scoped>
.selector {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.selector-label {
color: #2563eb;
font-weight: 600;
}
.selector-value {
color: #4b5563;
}
.popup-title {
font-weight: 600;
margin-bottom: 12px;
}
.report-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.report-item {
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-align: center;
color: #374151;
}
.report-item.active {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
}
.section-divider {
text-align: center;
color: #9ca3af;
font-size: 12px;
position: relative;
}
.section-divider::before,
.section-divider::after {
content: '';
position: absolute;
top: 50%;
width: 30%;
height: 1px;
background: #e5e7eb;
}
.section-divider::before {
left: 0;
}
.section-divider::after {
right: 0;
}
.custom-field {
margin-bottom: 12px;
}
.field-label {
display: block;
margin-bottom: 8px;
color: #4b5563;
font-weight: 500;
}
.field-input-wrap {
display: flex;
align-items: center;
background: #f9fafb;
border-radius: 8px;
padding: 8px 12px;
transition: all 0.3s ease;
border: 1px solid transparent;
}
.field-input {
flex: 1;
border: none;
outline: none;
background: transparent;
}
.field-unit {
margin-left: 8px;
color: #3b82f6;
font-weight: 500;
}
.custom-field:focus-within .field-input-wrap {
border-color: #bfdbfe;
}
.field-error .field-input-wrap {
border-color: #fca5a5;
background: #fef2f2;
}
</style>

36
src/pages/agent-vip.vue Normal file
View File

@@ -0,0 +1,36 @@
<script setup>
import { openCustomerService } from '@/composables/useCustomerService'
definePage({ layout: 'default', auth: true })
function toVipApply() {
uni.navigateTo({ url: '/pages/agent-vip-apply' })
}
function toService() {
openCustomerService()
}
</script>
<template>
<view class="relative">
<image class="block w-full" src="/static/images/vip_bg.png" mode="widthFix" />
<view
class="absolute bottom-80 left-[50%] flex flex-col translate-x-[-50%] items-center gap-4"
>
<view
class="rounded-lg from-amber-500 to-amber-600 bg-gradient-to-r px-6 py-2 text-[24px] text-white font-bold shadow-[0_0_15px_rgba(255,255,255,0.3)] transition-transform active:scale-105"
@click="toVipApply"
>
<text>申请VIP代理</text>
</view>
<view
class="rounded-lg from-gray-900 via-black to-gray-900 bg-gradient-to-r px-4 py-2 text-[20px] text-white font-bold shadow-[0_0_15px_rgba(255,255,255,0.3)] transition-transform active:scale-105"
@click="toService"
>
<text>联系客服</text>
</view>
</view>
</view>
</template>
<style lang="scss" scoped></style>

232
src/pages/agent.vue Normal file
View File

@@ -0,0 +1,232 @@
<script setup>
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
definePage({ layout: 'home' })
const agentStore = useAgentStore()
const { isAgent } = storeToRefs(agentStore)
const data = ref(null)
const dateRangeMap = {
today: 'today',
week: 'last7d',
month: 'last30d',
}
const dateTextMap = {
today: '今日',
week: '近7天',
month: '近1月',
}
const promoteDateOptions = [
{ label: '今日', value: 'today' },
{ label: '近7天', value: 'week' },
{ label: '近1月', value: 'month' },
]
const selectedPromoteDate = ref('today')
const teamDateOptions = [
{ label: '今日', value: 'today' },
{ label: '近7天', value: 'week' },
{ label: '近1月', value: 'month' },
]
const selectedTeamDate = ref('today')
const currentPromoteData = computed(() => {
const range = dateRangeMap[selectedPromoteDate.value]
return data.value?.direct_push?.[range] || { commission: 0, report: 0 }
})
const currentTeamData = computed(() => {
const range = dateRangeMap[selectedTeamDate.value]
return data.value?.active_reward?.[range] || {
sub_promote_reward: 0,
sub_upgrade_reward: 0,
sub_withdraw_reward: 0,
}
})
const promoteTimeText = computed(() => dateTextMap[selectedPromoteDate.value] || '今日')
const teamTimeText = computed(() => dateTextMap[selectedTeamDate.value] || '今日')
async function getData() {
try {
const { data: res, error } = await useApiFetch('/agent/revenue').get().json()
if (res.value?.code === 200 && !error.value) {
data.value = res.value.data
}
}
catch {
uni.showToast({ title: '网络错误', icon: 'none' })
}
}
onShow(() => {
if (isAgent.value) {
getData()
}
})
function goToPromoteDetail() {
uni.navigateTo({ url: '/pages/agent-promote-details' })
}
function goToRewardsDetail() {
uni.navigateTo({ url: '/pages/agent-rewards-details' })
}
function toWithdraw() {
uni.navigateTo({ url: '/pages/withdraw' })
}
function toWithdrawDetails() {
uni.navigateTo({ url: '/pages/withdraw-details' })
}
function toSubordinateList() {
uni.navigateTo({ url: '/pages/subordinate-list' })
}
</script>
<template>
<view class="safe-area-top min-h-screen p-4">
<view class="mb-4 rounded-xl from-blue-50/70 to-blue-100/50 bg-gradient-to-r p-6 shadow-lg">
<view class="mb-3 flex items-center justify-between">
<text class="text-lg text-gray-800 font-bold">
余额
</text>
<text class="text-3xl text-blue-600 font-bold">
¥ {{ (data?.balance || 0).toFixed(2) }}
</text>
</view>
<view class="mb-2 text-sm text-gray-500">
累计收益¥ {{ (data?.total_earnings || 0).toFixed(2) }}
</view>
<view class="mb-1 text-sm text-gray-500">
待结账金额¥ {{ (data?.frozen_balance || 0).toFixed(2) }}
</view>
<view class="mb-6 text-xs text-gray-400">
待结账金额将在订单创建24小时后自动结账
</view>
<view class="grid grid-cols-2 gap-3">
<wd-button type="primary" block @click="toWithdraw">
提现
</wd-button>
<wd-button plain block @click="toWithdrawDetails">
提现记录
</wd-button>
</view>
</view>
<view class="mb-4 rounded-xl from-blue-50/40 to-cyan-50/50 bg-gradient-to-r p-6 shadow-lg">
<view class="mb-4 flex items-center justify-between">
<text class="text-lg text-gray-800 font-bold">
直推报告收益
</text>
<view class="text-right">
<text class="text-2xl text-blue-600 font-bold">
¥ {{ (data?.direct_push?.total_commission || 0).toFixed(2) }}
</text>
<view class="mt-1 text-sm text-gray-500">
有效报告 {{ data?.direct_push?.total_report || 0 }}
</view>
</view>
</view>
<view class="grid grid-cols-3 mb-6 gap-2">
<view
v-for="item in promoteDateOptions"
:key="item.value"
class="rounded-full px-4 py-1 text-center text-sm transition-all"
:class="selectedPromoteDate === item.value ? 'bg-blue-500 text-white shadow-md' : 'border border-gray-200/50 bg-white/90 text-gray-600'"
@click="selectedPromoteDate = item.value"
>
{{ item.label }}
</view>
</view>
<view class="grid grid-cols-2 mb-6 gap-4">
<view class="rounded-lg bg-blue-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
{{ promoteTimeText }}收益
</view>
<text class="mt-1 text-xl text-blue-600 font-bold">
¥ {{ currentPromoteData.commission?.toFixed(2) || '0.00' }}
</text>
</view>
<view class="rounded-lg bg-blue-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
有效报告
</view>
<text class="mt-1 text-xl text-blue-600 font-bold">
{{ currentPromoteData.report || 0 }}
</text>
</view>
</view>
<view class="flex items-center justify-between text-sm text-blue-500 font-semibold" @click="goToPromoteDetail">
<text>查看收益明细</text>
<text class="text-lg">
</text>
</view>
</view>
<view class="rounded-xl from-green-50/40 to-cyan-50/30 bg-gradient-to-r p-6 shadow-lg">
<view class="mb-4">
<text class="text-lg text-gray-800 font-bold">
团队奖励
</text>
</view>
<view class="grid grid-cols-3 mb-6 gap-2">
<view
v-for="item in teamDateOptions"
:key="item.value"
class="rounded-full px-4 py-1 text-center text-sm transition-all"
:class="selectedTeamDate === item.value ? 'bg-green-500 text-white shadow-md' : 'border border-gray-200/50 bg-white/90 text-gray-600'"
@click="selectedTeamDate = item.value"
>
{{ item.label }}
</view>
</view>
<view class="grid grid-cols-1 mb-6 gap-2">
<view class="rounded-lg bg-green-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
{{ teamTimeText }}下级推广奖励
</view>
<text class="mt-1 text-xl text-green-600 font-bold">
¥ {{ (currentTeamData.sub_promote_reward || 0).toFixed(2) }}
</text>
</view>
<view class="rounded-lg bg-green-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
{{ teamTimeText }}下级转化奖励
</view>
<text class="mt-1 text-xl text-green-600 font-bold">
¥ {{ (currentTeamData.sub_upgrade_reward || 0).toFixed(2) }}
</text>
</view>
<view class="rounded-lg bg-green-50/60 p-3 backdrop-blur-sm">
<view class="text-sm text-gray-500">
{{ teamTimeText }}下级提现奖励
</view>
<text class="mt-1 text-xl text-green-600 font-bold">
¥ {{ (currentTeamData.sub_withdraw_reward || 0).toFixed(2) }}
</text>
</view>
</view>
<view class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<wd-button plain block @click="goToRewardsDetail">
团队奖励明细
</wd-button>
<wd-button type="success" block @click="toSubordinateList">
查看我的下级
</wd-button>
</view>
</view>
</view>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad((query) => {
const q = query || {}
src.value = buildSitePathUrl('/app/authorization', {
id: String(q.id || ''),
})
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,472 @@
<script setup>
import { storeToRefs } from 'pinia'
import { computed, nextTick, onUnmounted, ref } from 'vue'
import AccountCancelAgreement from '@/components/AccountCancelAgreement.vue'
import useApiFetch from '@/composables/useApiFetch'
import { useAgentStore } from '@/stores/agentStore'
import { useUserStore } from '@/stores/userStore'
import { clearAuthStorage } from '@/utils/storage'
definePage({ layout: false, auth: true })
const userStore = useUserStore()
const agentStore = useAgentStore()
const { level } = storeToRefs(agentStore)
const revenueData = ref(null)
const loading = ref(true)
const showBalancePopup = ref(false)
const showSmsPopup = ref(false)
const cancelAccountCode = ref('')
const verificationCodeInputRef = ref(null)
/** 与 login.vue / LoginDialog.vue 一致的倒计时逻辑 */
const isCountingDown = ref(false)
const countdown = ref(60)
let smsTimer = null
function toast(title) {
uni.showToast({ title, icon: 'none' })
}
function maskName(name) {
if (!name || name.length < 11)
return name
return `${name.substring(0, 3)}****${name.substring(7)}`
}
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(String(userStore.mobile || ''))
})
const isAgent = computed(() => agentStore.isAgent)
const hasWalletBalance = computed(() => {
const b = Number(revenueData.value?.balance ?? 0)
const f = Number(revenueData.value?.frozen_balance ?? 0)
return b > 0 || f > 0
})
const showBalanceWarning = computed(() => isAgent.value && hasWalletBalance.value)
const showVipLevelReminder = computed(() =>
isAgent.value && (level.value === 'VIP' || level.value === 'SVIP'),
)
const vipLevelLabel = computed(() => (level.value === 'SVIP' ? 'SVIP' : 'VIP'))
const showAnyReminder = computed(() => showBalanceWarning.value || showVipLevelReminder.value)
const canSubmitCancel = computed(() => cancelAccountCode.value.length === 6)
onLoad(async () => {
await userStore.fetchUserInfo()
if (!userStore.mobile) {
loading.value = false
toast('请先绑定手机号')
setTimeout(() => uni.navigateBack(), 1500)
return
}
try {
await agentStore.fetchAgentStatus()
if (agentStore.isAgent) {
const { data, error } = await useApiFetch('/agent/revenue').get().json()
if (!error.value && data.value?.code === 200)
revenueData.value = data.value.data
}
}
catch {
/* ignore */
}
finally {
loading.value = false
}
})
function onExit() {
uni.navigateBack()
}
function startCountdown() {
if (smsTimer) {
clearInterval(smsTimer)
}
isCountingDown.value = true
countdown.value = 60
smsTimer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(smsTimer)
smsTimer = null
isCountingDown.value = false
}
}, 1000)
}
function resetCountdown() {
if (smsTimer) {
clearInterval(smsTimer)
smsTimer = null
}
isCountingDown.value = false
countdown.value = 60
}
onUnmounted(() => {
resetCountdown()
})
function openSmsPopup() {
cancelAccountCode.value = ''
resetCountdown()
showSmsPopup.value = true
}
function closeSmsPopup() {
showSmsPopup.value = false
cancelAccountCode.value = ''
resetCountdown()
}
function onConfirmTap() {
if (showBalanceWarning.value) {
showBalancePopup.value = true
return
}
openSmsPopup()
}
function onBalanceContinue() {
showBalancePopup.value = false
openSmsPopup()
}
function onBalanceCancel() {
showBalancePopup.value = false
}
/** 与登录页 sendVerificationCode 一致:节流、校验手机号、成功后倒计时并聚焦验证码框 */
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!userStore.mobile) {
toast('请先绑定手机号')
return
}
try {
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: userStore.mobile, actionType: 'cancelAccount', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
toast('获取成功')
startCountdown()
nextTick(() => {
verificationCodeInputRef.value?.focus?.()
})
}
else {
toast(data.value?.msg || '发送失败')
}
}
catch {
toast('发送失败')
}
}
function clearAuthAndGoHome() {
clearAuthStorage()
userStore.resetUser()
agentStore.resetAgent()
uni.reLaunch({ url: '/pages/index' })
}
async function submitCancelAccount() {
if (!canSubmitCancel.value) {
toast('请输入6位验证码')
return
}
const { data, error } = await useApiFetch('/user/cancelOut')
.post({ code: cancelAccountCode.value })
.json()
if (!error.value && data.value?.code === 200) {
uni.showToast({ title: '账号已注销', icon: 'success' })
closeSmsPopup()
clearAuthAndGoHome()
}
else {
toast(data.value?.msg || '注销失败')
}
}
</script>
<template>
<view class="page-cancel-root box-border flex flex-col bg-gray-50">
<view v-if="loading" class="flex flex-1 items-center justify-center py-20 text-gray-500">
加载中...
</view>
<view v-else class="min-h-0 flex flex-1 flex-col">
<view class="min-h-0 flex flex-1 flex-col">
<scroll-view
scroll-y
:show-scrollbar="true"
class="box-border min-h-0 flex-1 bg-white"
style="flex: 1; height: 0; width: 100%;"
>
<AccountCancelAgreement />
</scroll-view>
<view
v-if="showAnyReminder"
class="flex-shrink-0 border-t border-gray-100 bg-gray-50 px-4 py-3 space-y-2"
>
<view
v-if="showBalanceWarning"
class="border border-amber-200 rounded-lg bg-amber-50 p-3 text-sm text-amber-900"
>
<text class="font-medium">
钱包提示
</text>
<text class="mt-1 block leading-relaxed">
检测到您为代理且账户仍有余额¥{{ (revenueData?.balance ?? 0).toFixed(2) }}或待结账金额¥{{ (revenueData?.frozen_balance ?? 0).toFixed(2) }}注销后将无法通过本账号提现请确认已了解风险
</text>
</view>
<view
v-if="showVipLevelReminder"
class="border border-violet-200 rounded-lg bg-violet-50 p-3 text-sm text-violet-900"
>
<text class="font-medium">
会员提示
</text>
<text class="mt-1 block leading-relaxed">
您当前为 {{ vipLevelLabel }} 代理会员注销后该账号下的代理身份与相关权益将按平台规则终止请确认已了解风险
</text>
</view>
</view>
</view>
<view class="cancel-footer flex-shrink-0 border-t border-gray-200 bg-white px-4 pt-3">
<view class="footer-btns flex gap-3">
<wd-button
class="footer-btn"
hairline
plain
block
type="info"
@click="onExit"
>
退出
</wd-button>
<wd-button
class="footer-btn"
type="error"
block
@click="onConfirmTap"
>
确认注销
</wd-button>
</view>
</view>
</view>
<!-- 有余额时的二次确认Wot -->
<wd-popup
v-model="showBalancePopup"
position="center"
round
:close-on-click-modal="true"
custom-style="width: 86%; max-width: 360px;"
@close="onBalanceCancel"
>
<view class="balance-popup p-5">
<view class="mb-2 text-center text-base text-gray-900 font-semibold">
确认注销
</view>
<view class="text-center text-sm text-gray-600 leading-relaxed">
您的代理账户仍有余额或待结账金额注销后将无法通过本账号提现确定继续注销
</view>
<view class="mt-5 flex gap-3">
<wd-button plain hairline type="info" block class="flex-1" @click="onBalanceCancel">
取消
</wd-button>
<wd-button type="error" block class="flex-1" @click="onBalanceContinue">
继续
</wd-button>
</view>
</view>
</wd-popup>
<!-- 短信验证与登录页表单风格一致 -->
<wd-popup
v-model="showSmsPopup"
position="bottom"
round
:safe-area-inset-bottom="true"
:style="{ maxHeight: '85vh' }"
:z-index="2000"
@close="closeSmsPopup"
>
<view class="sms-popup">
<view class="sms-popup-title">
<text class="sms-popup-title-text">
验证手机号
</text>
<wd-icon name="close" class="sms-popup-close" @click="closeSmsPopup" />
</view>
<view class="sms-popup-body">
<text class="sms-popup-desc">
将向 {{ maskName(userStore.mobile) }} 发送验证码请输入短信中的 6 位数字完成注销
</text>
<view class="sms-form-item">
<text class="sms-form-label">
验证码
</text>
<view class="sms-verification-wrap">
<wd-input
ref="verificationCodeInputRef"
v-model="cancelAccountCode"
class="sms-verification-input"
type="number"
placeholder="请输入验证码"
maxlength="6"
no-border
clearable
>
<template #suffix>
<wd-button
size="small"
type="primary"
plain
:disabled="isCountingDown || !isPhoneNumberValid"
@click="sendVerificationCode"
>
{{ isCountingDown ? `${countdown}s` : '获取验证码' }}
</wd-button>
</template>
</wd-input>
</view>
</view>
<view class="sms-popup-actions mt-6 flex gap-3">
<wd-button plain hairline type="info" block class="flex-1" @click="closeSmsPopup">
取消
</wd-button>
<wd-button
type="error"
block
class="flex-1"
:disabled="!canSubmitCancel"
@click="submitCancelAccount"
>
确认注销
</wd-button>
</view>
</view>
</view>
</wd-popup>
</view>
</template>
<style scoped>
.page-cancel-root {
height: 100%;
}
.cancel-footer {
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
/* 按钮区与屏幕底边留白,并叠加安全区 */
padding-bottom: calc(16px + constant(safe-area-inset-bottom));
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.footer-btn {
flex: 1;
min-width: 0;
}
.balance-popup {
box-sizing: border-box;
}
.sms-popup {
display: flex;
max-height: 85vh;
flex-direction: column;
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 28%);
}
.sms-popup-title {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f1f5f9;
padding: 14px 16px;
}
.sms-popup-title-text {
font-size: 1rem;
font-weight: 700;
color: #111827;
}
.sms-popup-close {
padding: 4px;
font-size: 22px;
color: #64748b;
}
.sms-popup-body {
overflow-y: auto;
padding: 16px 16px 24px;
padding-bottom: calc(24px + constant(safe-area-inset-bottom));
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
.sms-popup-desc {
display: block;
margin-bottom: 1.25rem;
font-size: 0.8125rem;
line-height: 1.55;
color: #64748b;
}
.sms-form-item {
display: flex;
align-items: center;
border-radius: 12px;
background-color: #fff;
padding: 0 0.75rem;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
}
.sms-form-label {
flex-shrink: 0;
margin-right: 1rem;
min-width: 4rem;
font-size: 0.9375rem;
font-weight: 500;
color: #111827;
}
.sms-verification-wrap {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
}
.sms-verification-input {
width: 100%;
}
.sms-popup-actions {
padding-top: 0.25rem;
}
</style>
<style>
page {
height: 100%;
}
</style>

91
src/pages/help-detail.vue Normal file
View File

@@ -0,0 +1,91 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute } from '@/composables/uni-router'
definePage({ layout: 'default' })
const route = useRoute()
const currentHelp = ref({
title: '',
image: '',
images: null,
})
// 图片路径映射
const imageMap = {
report_calculation: '/image/help/report-calculation.jpg',
report_efficiency: '/image/help/report-efficiency.jpg',
report_cost: '/image/help/report-cost.jpg',
report_types: '/image/help/report-types.jpg',
report_push: '/image/help/report-push.jpg',
report_secret: ['/image/help/report-secret-1.jpg', '/image/help/report-secret-2.jpg'],
invite_earnings: '/image/help/invite-earnings.jpg',
direct_earnings: '/image/help/direct-earnings.jpg',
vip_guide: '/image/help/vip-guide.jpg',
}
// 标题映射
const titleMap = {
report_calculation: '推广报告的收益是如何计算的?',
report_efficiency: '报告推广效率飙升指南',
report_cost: '推广报告的成本是如何计算的?',
report_types: '赤眉有哪些大数据报告类型',
report_push: '如何推广报告',
report_secret: '报告推广秘籍大公开',
invite_earnings: '如何邀请下级成为代理',
direct_earnings: '如何成为赤眉代理',
vip_guide: '如何成为VIP代理和SVIP代理?',
}
onMounted(() => {
const id = route.query.id
if (id && titleMap[id]) {
currentHelp.value = {
title: titleMap[id],
image: Array.isArray(imageMap[id]) ? null : imageMap[id],
images: Array.isArray(imageMap[id]) ? imageMap[id] : null,
}
}
})
</script>
<template>
<view class="help-detail">
<view class="help-detail-title">{{ currentHelp.title }}</view>
<template v-if="Array.isArray(currentHelp.images)">
<image
v-for="(image, index) in currentHelp.images"
:key="index"
:src="image"
class="help-image"
mode="widthFix"
/>
</template>
<image
v-else-if="currentHelp.image"
:src="currentHelp.image"
class="help-image"
mode="widthFix"
/>
</view>
</template>
<style lang="scss" scoped>
.help-detail {
min-height: 100vh;
padding: 20px;
background-color: #fff;
.help-detail-title {
margin: 0 0 20px;
font-size: 22px;
color: #323233;
font-weight: 500;
}
.help-image {
width: 100%;
border-radius: 8px;
margin-bottom: 12px;
}
}
</style>

105
src/pages/help-guide.vue Normal file
View File

@@ -0,0 +1,105 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from '@/composables/uni-router'
definePage({ layout: 'default' })
const route = useRoute()
const router = useRouter()
const currentStepIndex = ref(0)
// 引导步骤数据
const guideSteps = {
report_guide: [
{
title: '第一步:进入直推报告页面',
image: '/image/help/report-step1.jpg',
},
{
title: '第二步:选择报告类型',
image: '/image/help/report-step2.jpg',
},
{
title: '第三步:填写推广信息',
image: '/image/help/report-step3.jpg',
},
{
title: '第四步:完成推广',
image: '/image/help/report-step4.jpg',
},
],
invite_guide: [
{
title: '第一步:进入邀请页面',
image: '/image/help/invite-step1.jpg',
},
{
title: '第二步:获取邀请码',
image: '/image/help/invite-step2.jpg',
},
{
title: '第三步:分享邀请链接',
image: '/image/help/invite-step3.jpg',
},
],
}
const currentGuide = computed(() => {
const id = route.query.id
return guideSteps[id] || []
})
const totalSteps = computed(() => currentGuide.value.length)
const currentStep = computed(() => currentGuide.value[currentStepIndex.value])
function handleImageClick() {
if (currentStepIndex.value < totalSteps.value - 1) {
currentStepIndex.value++
}
else {
// 最后一步,返回列表页
router.back()
}
}
function onClickLeft() {
router.back()
}
onMounted(() => {
const id = route.query.id
if (!guideSteps[id]) {
router.back()
}
})
</script>
<template>
<view class="help-guide">
<view class="guide-content" @click="handleImageClick">
<image :src="currentStep.image" class="guide-image" mode="widthFix" />
</view>
</view>
</template>
<style lang="scss" scoped>
.help-guide {
background-color: #666666;
display: flex;
flex-direction: column;
height: calc(100vh - 46px);
.guide-content {
flex: 1;
height: calc(100vh - 46px);
overflow: hidden;
position: relative;
.guide-image {
width: 100%;
object-fit: contain;
display: block;
}
}
}
</style>

161
src/pages/help.vue Normal file
View File

@@ -0,0 +1,161 @@
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from '@/composables/uni-router'
definePage({ layout: 'default' })
const router = useRouter()
const activeTab = ref('report')
const categories = [
{
title: '推广报告',
name: 'report',
items: [
{ id: 'report_guide', title: '直推报告页面引导', type: 'guide' },
{ id: 'invite_guide', title: '邀请下级页面引导', type: 'guide' },
{ id: 'direct_earnings', title: '如何成为赤眉代理' },
{ id: 'report_push', title: '如何推广报告' },
{ id: 'report_calculation', title: '推广报告的收益是如何计算的?' },
{ id: 'report_cost', title: '推广报告的成本是如何计算的?' },
{ id: 'report_efficiency', title: '报告推广效率飙升指南' },
{ id: 'report_secret', title: '报告推广秘籍大公开' },
{ id: 'report_types', title: '赤眉有哪些大数据报告类型' },
],
},
{
title: '邀请下级',
name: 'invite',
items: [
{ id: 'invite_earnings', title: '邀请下级赚取收益' },
],
},
{
title: '其他',
name: 'other',
items: [
{ id: 'vip_guide', title: '如何成为VIP代理和SVIP代理?' },
],
},
]
const currentCategory = computed(() => {
return categories.find(item => item.name === activeTab.value) || categories[0]
})
function goToDetail(id, type) {
if (type === 'guide') {
router.push({
path: '/help/guide',
query: { id },
})
}
else {
router.push({
path: '/help/detail',
query: { id },
})
}
}
</script>
<template>
<div class="help-center">
<view class="tab-bar">
<view
v-for="(category, index) in categories"
:key="index"
class="tab-item"
:class="{ active: activeTab === category.name }"
@click="activeTab = category.name"
>
{{ category.title }}
</view>
</view>
<wd-cell-group border class="help-list">
<wd-cell
v-for="item in currentCategory.items"
:key="item.id"
:title="item.title"
clickable
class="help-item"
@click="goToDetail(item.id, item.type)"
>
<template v-if="item.type === 'guide'" #label>
<text class="guide-tag">
引导指南
</text>
</template>
</wd-cell>
</wd-cell-group>
</div>
</template>
<style lang="scss" scoped>
.help-center {
min-height: 100vh;
background-color: #f7f8fa;
padding: 12px;
.tab-bar {
display: flex;
background: #fff;
border-radius: 10px;
padding: 4px;
gap: 6px;
}
.tab-item {
flex: 1;
text-align: center;
padding: 8px 0;
border-radius: 8px;
color: #666;
font-size: 14px;
}
.tab-item.active {
background: #2563eb;
color: #fff;
font-weight: 600;
}
.help-list {
margin-top: 12px;
border-radius: 12px;
overflow: hidden;
.guide-tag {
margin-top: 4px;
display: inline-flex;
padding: 2px 8px;
font-size: 12px;
border-radius: 10px;
color: #fff;
background-color: #2563eb;
}
}
}
.help-detail {
padding: 20px;
&-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
}
}
&-content {
white-space: pre-line;
line-height: 1.6;
color: #666;
}
}
</style>

140
src/pages/history-query.vue Normal file
View File

@@ -0,0 +1,140 @@
<script setup>
import { onMounted, ref } from 'vue'
definePage({ layout: 'default', auth: true })
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const reportList = ref([])
const loading = ref(false)
// 初始加载数据
async function fetchData() {
loading.value = true
const { data, error } = await useApiFetch(`query/list?page=${page.value}&page_size=${pageSize.value}`)
.get()
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
total.value = data.value.data.total
reportList.value = data.value.data.list || []
}
}
loading.value = false
}
// 初始加载
onMounted(() => {
fetchData()
})
function onPageChange({ value }) {
page.value = value
fetchData()
}
function toDetail(item) {
if (item.query_state !== 'success')
return
uni.navigateTo({
url: `/pages/report-result-webview?order_id=${encodeURIComponent(String(item.order_id || ''))}`,
})
}
// 状态文字映射
function stateText(state) {
switch (state) {
case 'pending':
return '查询中'
case 'success':
return '查询成功'
case 'failed':
return '查询失败'
case 'refunded':
return '已退款'
default:
return '未知状态'
}
}
// 状态颜色映射
function statusClass(state) {
switch (state) {
case 'pending':
return 'status-pending'
case 'success':
return 'status-success'
case 'failed':
return 'status-failed'
case 'refunded':
return 'status-refunded'
default:
return ''
}
}
</script>
<template>
<view class="flex flex-col gap-4 p-4">
<view class="history-scroll">
<view
v-for="item in reportList" :key="item.id" class="relative mb-4 cursor-pointer rounded-lg bg-white p-4 shadow-sm"
@click="toDetail(item)"
>
<view class="flex flex-col">
<view class="mb-1 text-xl text-black">
{{ item.product_name }}
</view>
<view class="text-sm text-[#999999]">
{{ item.create_time }}
</view>
</view>
<view
class="absolute right-0 top-0 rounded-bl-lg rounded-tr-lg px-2 py-[1px] text-sm text-white font-medium"
:class="[statusClass(item.query_state)]"
>
{{ stateText(item.query_state) }}
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!reportList.length" class="py-4 text-center text-sm text-gray-400">
暂无记录
</view>
</view>
<wd-pagination
v-model="page"
:total="total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</view>
</template>
<style scoped>
.history-scroll {
height: calc(100vh - 120px);
}
.status-pending {
background-color: #1976d2;
color: white;
}
.status-success {
background-color: #1FBE5D;
color: white;
}
.status-failed {
background-color: #EB3C3C;
color: white;
}
.status-refunded {
background-color: #999999;
color: white;
}
</style>

179
src/pages/index.vue Normal file
View File

@@ -0,0 +1,179 @@
<script setup>
import bgIcon from '/static/images/bg_icon.png'
import bannerImg from '/static/images/index/banner_1.png'
import bannerImg2 from '/static/images/index/banner_2.png'
import bannerImg3 from '/static/images/index/banner_3.png'
import honestyBanner from '/static/images/index/banner_B.png'
import companyIcon from '/static/images/index/company_bg.png'
import housekeepingRiskIcon from '/static/images/index/housekeeping_risk_bg.png'
import loanCheckIcon from '/static/images/index/loan_check_bg.png'
import marriageRiskIcon from '/static/images/index/marriage_risk_bg.png'
import personalDataIcon from '/static/images/index/personal_data_bg.png'
import preLoanRiskIcon from '/static/images/index/preloan_risk_bg.png'
import rentalInfoBg from '/static/images/index/rentalinfo_bg.png'
import rightIcon from '/static/images/index/right.png'
import indexPromoteIcon from '/static/images/index/tgbg.png'
import indexMyReportIcon from '/static/images/index/wdbg.png'
import indexInvitationIcon from '/static/images/index/yqhy.png'
definePage({ type: 'home', layout: 'home' })
const banners = [bannerImg, bannerImg2, bannerImg3]
const services = [
{ title: '个人大数据', name: 'personalData', bg: personalDataIcon },
{ title: '婚恋风险', name: 'marriage', bg: marriageRiskIcon },
{ title: '入职背调', name: 'backgroundcheck', bg: preLoanRiskIcon },
]
const riskServices = [
{ title: '小微', name: 'companyinfo', bg: companyIcon },
{ title: '家政', name: 'homeservice', bg: housekeepingRiskIcon },
{ title: '贷前', name: 'preloanbackgroundcheck', bg: loanCheckIcon },
{ title: '诚信租赁', name: 'rentalinfo', bg: rentalInfoBg },
]
function toInquire(name) {
uni.navigateTo({
url: `/pages/inquire?feature=${encodeURIComponent(name)}`,
})
}
function toInvitation() {
uni.navigateTo({ url: '/pages/invitation' })
}
function toPromote() {
uni.navigateTo({ url: '/pages/promote' })
}
function toHistory() {
uni.navigateTo({ url: '/pages/history-query' })
}
</script>
<template>
<view class="box-border min-h-screen">
<view class="relative p-4">
<swiper
class="banner-swiper overflow-hidden rounded-xl" circular autoplay :interval="3000" indicator-dots
indicator-color="rgba(255,255,255,0.5)" indicator-active-color="#ffffff"
>
<swiper-item v-for="(item, index) in banners" :key="index">
<image :src="item" class="h-full w-full" mode="aspectFill" />
</swiper-item>
</swiper>
</view>
<view class="px-6">
<view class="grid grid-cols-3 gap-3">
<view class="flex flex-col items-center justify-center text-center" @click="toPromote">
<view
class="box-content h-16 w-16 flex items-center justify-center rounded-full from-white to-blue-100/10 bg-gradient-to-b p-1 shadow-lg"
>
<image :src="indexPromoteIcon" class="h-12 w-12" mode="aspectFit" />
</view>
<text class="mt-1 text-center font-bold">
推广报告
</text>
</view>
<view class="flex flex-col items-center justify-center text-center" @click="toInvitation">
<view
class="box-content h-16 w-16 flex items-center justify-center rounded-full from-white to-blue-100/10 bg-gradient-to-b p-1 shadow-lg"
>
<image :src="indexInvitationIcon" class="h-12 w-12" mode="aspectFit" />
</view>
<text class="mt-1 text-center font-bold">
邀请下级
</text>
</view>
<view class="flex flex-col items-center justify-center text-center" @click="toHistory">
<view
class="box-content h-16 w-16 flex items-center justify-center rounded-full from-white to-blue-100/10 bg-gradient-to-b p-1 shadow-lg"
>
<image :src="indexMyReportIcon" class="h-12 w-12" mode="aspectFit" />
</view>
<text class="mt-1 text-center font-bold">
我的报告
</text>
</view>
</view>
</view>
<view class="relative p-4 pt-0">
<view class="grid grid-cols-2 my-4 gap-4" style="grid-template-rows: repeat(2, 1fr);">
<view
v-for="(service, index) in services" :key="index"
class="relative min-h-18 flex flex-col rounded-xl px-4 py-2 shadow-lg"
:class="index === 0 ? 'row-span-2' : ''"
:style="`background: url(${service.bg}) no-repeat; background-size: 100% 100%; background-position: center;`"
@click="toInquire(service.name)"
>
<view class="min-h-18 flex items-end">
<!-- <text class="text-base text-gray-700 font-semibold">
{{ service.title }}
</text> -->
</view>
</view>
</view>
<scroll-view scroll-x class="risk-scroll my-4 px-1 pb-4 pt-2 -mx-1">
<view class="inline-flex gap-2">
<view
v-for="(service, index) in riskServices" :key="index"
class="relative h-24 w-[107px] flex-shrink-0 rounded-xl shadow-lg"
:style="`background: url(${service.bg}) no-repeat; background-size: 100% 100%; background-position: center;`"
@click="toInquire(service.name)"
>
<view class="h-full flex items-end px-2 py-2">
<!-- <text class="text-sm text-gray-700 font-semibold">
{{ service.title }}
</text> -->
</view>
</view>
</view>
</scroll-view>
<view class="mb-3 mt-6 flex items-center">
<view class="bg-primary h-5 w-1.5 rounded-xl" />
<text class="ml-2 text-lg text-gray-800">
诚信专栏
</text>
</view>
<view class="mt-4 overflow-hidden rounded-xl bg-white shadow-xl">
<image :src="honestyBanner" class="block w-full" mode="widthFix" />
</view>
<view
class="mt-4 box-border h-14 w-full flex items-center rounded-lg bg-white px-4 text-gray-700 shadow-lg"
@click="toHistory"
>
<view class="mr-4 h-full flex items-center justify-center">
<image class="h-10 w-10" :src="bgIcon" mode="aspectFit" />
</view>
<view class="flex-1">
<view class="text-gray-800">
我的历史查询记录
</view>
<view class="text-xs text-gray-500">
查询记录有效期为30天
</view>
</view>
<image :src="rightIcon" class="h-6 w-6" mode="aspectFit" />
</view>
</view>
</view>
</template>
<style scoped>
.banner-swiper {
height: 160px;
}
.risk-scroll {
white-space: nowrap;
}
</style>

54
src/pages/inquire.vue Normal file
View File

@@ -0,0 +1,54 @@
<script setup>
import { ref } from 'vue'
import InquireForm from '@/components/InquireForm.vue'
definePage({ layout: 'default' })
const feature = ref('')
const IMMERSIVE_NAVBAR_ROUTE = 'pages/inquire'
// 获取产品信息
const featureData = ref({})
onLoad(async (query) => {
const q = query || {}
feature.value = String(q.feature || '')
if (q.out_trade_no) {
uni.navigateTo({
url: `/pages/report-result-webview?out_trade_no=${encodeURIComponent(String(q.out_trade_no))}`,
})
return
}
await getProduct()
})
async function getProduct() {
if (!feature.value)
return
const { data } = await useApiFetch(`/product/en/${feature.value}`)
.get()
.json()
if (data.value) {
featureData.value = data.value.data
}
}
onPageScroll((event) => {
uni.$emit('immersive-navbar-change', {
route: IMMERSIVE_NAVBAR_ROUTE,
solid: event.scrollTop > 120,
})
})
onUnload(() => {
uni.$emit('immersive-navbar-change', {
route: IMMERSIVE_NAVBAR_ROUTE,
solid: false,
})
})
</script>
<template>
<InquireForm type="normal" :feature="feature" :feature-data="featureData" />
</template>

View File

@@ -0,0 +1,202 @@
<script setup>
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { aesDecrypt } from '@/utils/crypto'
import { getToken, setAuthSession } from '@/utils/storage'
definePage({ layout: 'default' })
let intervalId = null
const POLL_INTERVAL_MS = 2000
const showApplyPopup = ref(false)
const route = useRoute()
const store = useAgentStore()
const userStore = useUserStore()
const { isLoggedIn, mobile } = storeToRefs(userStore)
const { status } = storeToRefs(store) // 响应式解构
const ancestor = ref('')
const isSelf = ref(false)
const isApplyPolling = ref(false)
function stopApplyStatusPolling() {
if (intervalId) {
clearInterval(intervalId)
intervalId = null
}
isApplyPolling.value = false
}
function startApplyStatusPolling() {
if (intervalId || status.value === 1 || status.value === 2)
return
isApplyPolling.value = true
intervalId = setInterval(async () => {
await store.fetchAgentStatus()
if (status.value === 1 || status.value === 2)
stopApplyStatusPolling()
}, POLL_INTERVAL_MS)
}
async function syncStatusAndUpdatePolling() {
await store.fetchAgentStatus()
if (status.value === 0 || status.value === 3) {
startApplyStatusPolling()
}
else {
stopApplyStatusPolling()
}
}
function agentApply() {
showApplyPopup.value = true
}
// 计算显示状态当isSelf为false时强制显示为3
const displayStatus = computed(() => {
// return isSelf.value ? status.value : 3;
return status.value
})
// 跳转到首页
function goToHome() {
stopApplyStatusPolling()
uni.reLaunch({ url: '/pages/index' })
}
onBeforeMount(async () => {
const channelKey = import.meta.env.VITE_INVITE_CHANNEL_KEY
if (!channelKey) {
uni.showToast({ title: '缺少邀请渠道配置', icon: 'none' })
return
}
if (route.name === 'invitationAgentApplySelf') {
isSelf.value = true
}
else {
const linkIdentifier = route.params.linkIdentifier
const decryptDataStr = aesDecrypt(
decodeURIComponent(linkIdentifier),
channelKey,
)
const decryptData = JSON.parse(decryptDataStr)
ancestor.value = decryptData.mobile
}
const token = getToken()
if (token) {
await syncStatusAndUpdatePolling()
}
})
async function submitApplication(formData) {
// 提交代理申请的数据
const { region, mobile, wechat_id, code } = formData
const postData = {
region,
mobile,
wechat_id,
code,
}
if (!isSelf.value) {
postData.ancestor = ancestor.value
}
const { data, error } = await useApiFetch('/agent/apply')
.post(postData)
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
showApplyPopup.value = false
showToast({ message: '已提交申请' })
if (data.value.data.accessToken) {
setAuthSession(data.value.data)
}
if (getToken()) {
await syncStatusAndUpdatePolling()
}
}
else {
console.warn('申请失败', data.value)
}
}
}
watch(status, (nextStatus, prevStatus) => {
if (!isApplyPolling.value)
return
if ((prevStatus === 0 || prevStatus === 3) && nextStatus === 1) {
showToast({ message: '审核已通过' })
}
else if ((prevStatus === 0 || prevStatus === 3) && nextStatus === 2) {
showToast({ message: '审核未通过,请重新提交' })
}
})
onUnmounted(() => {
stopApplyStatusPolling()
})
</script>
<template>
<view class="min-h-screen bg-[#D1D6FF]">
<image class="block w-full" src="/static/images/invitation_agent_apply.png" mode="widthFix" />
<!-- 统一状态处理容器 -->
<view class="flex flex-col items-center justify-center">
<!-- 审核中状态 -->
<view v-if="displayStatus === 0" class="text-center">
<text class="text-xs text-gray-500">您的申请正在审核中</text>
<view class="mt-1 rounded-3xl bg-gray-200 p-1 shadow-xl">
<view class="rounded-3xl bg-gray-400 px-8 py-2 text-xl text-white font-bold shadow-lg opacity-90">
<text>审核进行中</text>
</view>
</view>
</view>
<!-- 审核通过状态 -->
<view v-if="displayStatus === 1" class="text-center">
<text class="text-xs text-gray-500">您已成为认证代理方</text>
<view class="mt-1 rounded-3xl bg-green-100 p-1 shadow-xl" @click="goToHome">
<view
class="rounded-3xl from-green-500 to-green-300 bg-gradient-to-t px-8 py-2 text-xl text-white font-bold shadow-lg">
<text>进入应用首页</text>
</view>
</view>
</view>
<!-- 审核未通过状态 -->
<view v-if="displayStatus === 2" class="text-center">
<text class="text-xs text-red-500">审核未通过请重新提交</text>
<view class="mt-1 rounded-3xl bg-red-100 p-1 shadow-xl" @click="agentApply">
<view
class="rounded-3xl from-red-500 to-red-300 bg-gradient-to-t px-8 py-2 text-xl text-white font-bold shadow-lg">
<text>重新提交申请</text>
</view>
</view>
</view>
<!-- 未申请状态包含邀请状态 -->
<view v-if="displayStatus === 3" class="text-center">
<text class="text-xs text-gray-500">
{{ isSelf ? '立即申请成为代理人' : '邀您注册代理人' }}
</text>
<view class="mt-1 rounded-3xl bg-gray-100 p-1 shadow-xl" @click="agentApply">
<view
class="rounded-3xl from-blue-500 to-blue-300 bg-gradient-to-t px-8 py-2 text-xl text-white font-bold shadow-lg">
<text>立即成为代理方</text>
</view>
</view>
</view>
</view>
</view>
<AgentApplicationForm
v-model:show="showApplyPopup"
:ancestor="ancestor"
:is-self="isSelf"
:is-logged-in="isLoggedIn"
:user-mobile="mobile"
@submit="submitApplication"
@close="showApplyPopup = false"
/>
</template>
<style lang="scss" scoped></style>

67
src/pages/invitation.vue Normal file
View File

@@ -0,0 +1,67 @@
<script setup>
import { storeToRefs } from 'pinia'
import { onMounted } from 'vue'
import { useAgentStore } from '@/stores/agentStore'
import { aesEncrypt } from '@/utils/crypto'
definePage({ layout: 'default', auth: true })
const agentStore = useAgentStore()
const { mobile, agentID } = storeToRefs(agentStore)
const showQRcode = ref(false)
const linkIdentifier = ref('')
function encryptIdentifire(id, phone) {
const channelKey = import.meta.env.VITE_INVITE_CHANNEL_KEY
if (!channelKey) {
uni.showToast({ title: '缺少邀请渠道配置', icon: 'none' })
return
}
const linkIdentifierJSON = {
agentID: id,
mobile: phone,
}
const linkIdentifierStr = JSON.stringify(linkIdentifierJSON)
const encodeData = aesEncrypt(
linkIdentifierStr,
channelKey,
)
linkIdentifier.value = encodeURIComponent(encodeData)
}
onMounted(async () => {
try {
if (!agentID.value)
await agentStore.fetchAgentStatus()
encryptIdentifire(agentID.value || '', mobile.value || '')
}
catch (e) {
console.error('[invitation] 初始化失败', e)
uni.showToast({ title: '加载代理信息失败', icon: 'none' })
}
})
</script>
<template>
<view class="relative min-h-screen w-full pb-[calc(3rem+env(safe-area-inset-bottom))]">
<image
class="block w-full"
src="/static/images/invitation.png"
mode="widthFix"
/>
<view
class="fixed inset-x-0 bottom-0 z-10 min-h-12 flex items-center justify-center rounded-t-xl from-orange-500 to-orange-300 bg-gradient-to-t px-4 pb-[env(safe-area-inset-bottom)] pt-2 text-base text-white font-bold shadow-xl"
@click="showQRcode = true"
>
<text>立即邀请好友</text>
</view>
</view>
<QRcode
v-model:show="showQRcode"
mode="invitation"
:link-identifier="linkIdentifier"
/>
</template>
<style lang="scss" scoped></style>

297
src/pages/login.vue Normal file
View File

@@ -0,0 +1,297 @@
<script setup>
import { computed, onUnmounted, ref } from 'vue'
definePage({ layout: false })
const phoneNumber = ref('')
const verificationCode = ref('')
const isAgreed = ref(false)
const isCountingDown = ref(false)
const countdown = ref(60)
const redirectUrl = ref('')
let timer = null
const userStore = useUserStore()
const agentStore = useAgentStore()
function toast(title) {
uni.showToast({ title, icon: 'none' })
}
const isPhoneNumberValid = computed(() => {
return /^1[3-9]\d{9}$/.test(phoneNumber.value)
})
const canLogin = computed(() => {
if (!isPhoneNumberValid.value)
return false
return verificationCode.value.length === 6
})
async function sendVerificationCode() {
if (isCountingDown.value || !isPhoneNumberValid.value)
return
if (!isPhoneNumberValid.value) {
toast('请输入有效的手机号')
return
}
try {
const { data, error } = await useApiFetch('auth/sendSms')
.post({ mobile: phoneNumber.value, actionType: 'login', captchaVerifyParam: '' })
.json()
if (!error.value && data.value?.code === 200) {
toast('获取成功')
startCountdown()
return
}
toast(data.value?.msg || '发送失败')
}
catch {
toast('发送失败')
}
}
function startCountdown() {
isCountingDown.value = true
countdown.value = 60
timer = setInterval(() => {
if (countdown.value > 0) {
countdown.value--
}
else {
clearInterval(timer)
isCountingDown.value = false
}
}, 1000)
}
async function handleLogin() {
if (!isPhoneNumberValid.value) {
toast('请输入有效的手机号')
return
}
if (verificationCode.value.length !== 6) {
toast('请输入有效的验证码')
return
}
if (!isAgreed.value) {
toast('请先同意用户协议')
return
}
performLogin()
}
// 执行实际的登录逻辑
async function performLogin() {
const { data, error } = await useApiFetch('/user/mobileCodeLogin')
.post({ mobile: phoneNumber.value, code: verificationCode.value })
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
uni.setStorageSync('token', data.value.data.accessToken)
uni.setStorageSync('refreshAfter', data.value.data.refreshAfter)
uni.setStorageSync('accessExpire', data.value.data.accessExpire)
await userStore.fetchUserInfo()
await agentStore.fetchAgentStatus()
uni.reLaunch({ url: redirectUrl.value || '/pages/index' })
}
else {
toast(data.value.msg || '登录失败')
}
}
else {
toast('登录失败')
}
}
function toUserAgreement() {
uni.navigateTo({ url: '/pages/user-agreement' })
}
function toPrivacyPolicy() {
uni.navigateTo({ url: '/pages/privacy-policy' })
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
function onClickLeft() {
uni.reLaunch({ url: '/pages/index' })
}
onLoad((query) => {
const raw = String(query?.redirect || '')
if (!raw)
return
try {
redirectUrl.value = decodeURIComponent(raw)
}
catch {
redirectUrl.value = raw
}
})
</script>
<template>
<view class="login-layout">
<wd-navbar title="用户登录" safe-area-inset-top left-arrow placeholder fixed @click-left="onClickLeft" />
<view class="login relative z-10 px-4">
<view class="mb-8 pt-20 text-left">
<view class="flex flex-col items-center">
<image class="h-16 w-16 rounded-full shadow" src="/static/images/logo.png" alt="Logo" />
<view class="mt-4 text-3xl text-slate-700 font-bold">
赤眉
</view>
</view>
</view>
<!-- 登录表单 -->
<view class="login-form">
<!-- 手机号输入 -->
<view class="form-item">
<text class="form-label">
手机号
</text>
<wd-input v-model="phoneNumber" class="phone-wd-input" type="number" placeholder="请输入手机号" maxlength="11"
no-border clearable />
</view>
<!-- 验证码输入 -->
<view class="form-item">
<text class="form-label">
验证码
</text>
<view class="verification-input-wrapper">
<wd-input v-model="verificationCode" class="verification-wd-input" placeholder="请输入验证码" maxlength="6"
no-border clearable>
<template #suffix>
<wd-button size="small" type="primary" plain :disabled="isCountingDown || !isPhoneNumberValid"
@click="sendVerificationCode">
{{ isCountingDown ? `${countdown}s` : '获取验证码' }}
</wd-button>
</template>
</wd-input>
</view>
</view>
<!-- 协议同意框 -->
<view class="agreement-wrapper">
<wd-checkbox v-model="isAgreed" shape="square" size="18px" />
<text class="agreement-text">
我已阅读并同意
<text class="agreement-link" @click="toUserAgreement">
用户协议
</text>
<text class="agreement-link" @click="toPrivacyPolicy">
隐私政策
</text>
</text>
</view>
<!-- 提示文字 -->
<view class="notice-text">
未注册手机号登录后将自动生成账号并且代表您已阅读并同意
</view>
<!-- 登录按钮 -->
<wd-button class="login-wd-btn" block type="primary" :disabled="!canLogin" @click="handleLogin">
</wd-button>
</view>
</view>
</view>
</template>
<style scoped>
.login-layout {
background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%);
background-position: center;
background-repeat: no-repeat;
background-size: cover;
min-height: 100vh;
position: relative;
padding-bottom: 24px;
}
/* 登录表单 */
.login-form {
margin-top: 0.5rem;
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 28px rgba(63, 63, 63, 0.1);
padding: 1.5rem 1.25rem;
backdrop-filter: blur(4px);
}
/* 表单项 */
.form-item {
margin-bottom: 1.25rem;
display: flex;
align-items: center;
border-radius: 12px;
background-color: #fff;
padding: 0 0.75rem;
}
.form-label {
font-size: 0.9375rem;
color: #111827;
margin-bottom: 0;
margin-right: 1rem;
font-weight: 500;
min-width: 4rem;
flex-shrink: 0;
}
.phone-wd-input {
flex: 1;
}
/* 验证码输入 */
.verification-input-wrapper {
display: flex;
align-items: center;
flex: 1;
}
.verification-wd-input {
width: 100%;
}
/* 协议同意 */
.agreement-wrapper {
display: flex;
align-items: center;
margin-top: 1.25rem;
margin-bottom: 1rem;
}
.agreement-text {
font-size: 0.75rem;
color: #6b7280;
line-height: 1.4;
margin-left: 0.5rem;
}
.agreement-link {
color: #2563eb;
cursor: pointer;
text-decoration: none;
}
/* 提示文字 */
.notice-text {
font-size: 0.6875rem;
color: #9ca3af;
line-height: 1.5;
margin-bottom: 1.25rem;
}
/* 登录按钮 */
.login-wd-btn {
letter-spacing: 0.25rem;
}
</style>

360
src/pages/me.vue Normal file
View File

@@ -0,0 +1,360 @@
<script setup>
import { storeToRefs } from 'pinia'
import { computed, onBeforeMount, ref, watch } from 'vue'
import { openCustomerService } from '@/composables/useCustomerService'
import { useEnv } from '@/composables/useEnv'
import { useAgentStore } from '@/stores/agentStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useUserStore } from '@/stores/userStore'
import headShot from '/static/images/head_shot.webp'
definePage({ layout: 'home' })
const agentStore = useAgentStore()
const userStore = useUserStore()
const dialogStore = useDialogStore()
const { isAgent, level, ExpiryTime } = storeToRefs(agentStore)
const { userAvatar, isLoggedIn, mobile } = storeToRefs(userStore)
const { isWeChat } = useEnv()
const levelNames = {
'normal': '普通代理',
'': '普通代理',
'VIP': 'VIP代理',
'SVIP': 'SVIP代理',
}
const levelText = {
'normal': '基础代理特权',
'': '基础代理特权',
'VIP': '高级代理特权',
'SVIP': '尊享代理特权',
}
const levelGradient = computed(() => ({
border: {
'normal': '',
'': '',
'VIP': '',
'SVIP': '',
}[level.value],
badge: {
'normal': 'bg-gradient-to-r from-gray-500 to-gray-600',
'': 'bg-gradient-to-r from-gray-500 to-gray-600',
'VIP': 'bg-gradient-to-r from-yellow-500 to-amber-600',
'SVIP': 'bg-gradient-to-r from-purple-500 to-pink-500',
}[level.value],
text: {
'normal': 'text-gray-600',
'': 'text-gray-600',
'VIP': 'text-amber-600',
'SVIP': 'text-purple-600',
}[level.value],
}))
function maskName(name) {
if (!name || name.length < 11)
return name
return `${name.substring(0, 3)}****${name.substring(7)}`
}
function toHistory() {
uni.navigateTo({ url: '/pages/history-query' })
}
function toPromote() {
uni.navigateTo({ url: '/pages/promote' })
}
function toInvitation() {
uni.navigateTo({ url: '/pages/invitation' })
}
function toUserAgreement() {
uni.navigateTo({ url: '/pages/user-agreement' })
}
function toPrivacyPolicy() {
uni.navigateTo({ url: '/pages/privacy-policy' })
}
function redirectToLogin() {
uni.navigateTo({ url: '/pages/login' })
}
function handleLogout() {
uni.removeStorageSync('token')
uni.removeStorageSync('refreshAfter')
uni.removeStorageSync('accessExpire')
uni.removeStorageSync('userInfo')
uni.removeStorageSync('agentInfo')
// 重置状态
userStore.resetUser()
agentStore.resetAgent()
uni.reLaunch({ url: '/pages/index' })
}
function goCancelAccountPage() {
if (!mobile.value) {
uni.showToast({ title: '请先绑定手机号', icon: 'none' })
return
}
uni.navigateTo({ url: '/pages/cancel-account' })
}
function toService() {
openCustomerService()
}
function toVipConfig() {
uni.navigateTo({ url: '/pages/agent-vip-config' })
}
function toVipRenewal() {
uni.navigateTo({ url: '/pages/agent-vip-apply' })
}
function formatExpiryTime(expiryTimeStr) {
if (!expiryTimeStr)
return '未知'
// 假设expiryTimeStr格式是 "YYYY-MM-DD HH:MM:SS"
// 只返回日期部分 "YYYY-MM-DD"
return expiryTimeStr.split(' ')[0]
}
/** 与 bdrp-mini `/static/image/shot_*.png` 一致,资源放在 `src/static/image/` */
function getDefaultAvatar() {
if (!isAgent.value)
return headShot
const normalizedLevel = String(level.value || '').toUpperCase()
switch (normalizedLevel) {
case 'NORMAL':
case 'normal':
case '':
return '/static/image/shot_nonal.png'
case 'VIP':
return '/static/image/shot_vip.png'
case 'SVIP':
return '/static/image/shot_svip.png'
default:
return headShot
}
}
/** 用户头像 URLstore 已 resolve默认图为本地资源加载失败时回退到 headShot */
const avatarDisplay = ref(headShot)
function syncAvatarDisplay() {
avatarDisplay.value = userAvatar.value || getDefaultAvatar()
}
watch([userAvatar, isAgent, level], syncAvatarDisplay, { immediate: true })
function onAvatarError() {
avatarDisplay.value = headShot
}
function showBindPhoneDialog() {
dialogStore.openBindPhone()
}
onBeforeMount(() => {
// 获取存储的用户和代理信息
const userInfo = uni.getStorageSync('userInfo')
if (userInfo) {
try {
const parsedUserInfo = typeof userInfo === 'string' ? JSON.parse(userInfo) : userInfo
userStore.updateUserInfo(parsedUserInfo)
}
catch (e) {
console.error('解析用户信息失败', e)
}
}
const agentInfo = uni.getStorageSync('agentInfo')
if (agentInfo) {
try {
const parsedAgentInfo = typeof agentInfo === 'string' ? JSON.parse(agentInfo) : agentInfo
agentStore.updateAgentInfo(parsedAgentInfo)
}
catch (e) {
console.error('解析代理信息失败', e)
}
}
})
</script>
<template>
<view class="box-border min-h-screen">
<view class="flex flex-col p-4 space-y-6">
<!-- 用户信息卡片 -->
<view
class="group profile-section relative flex items-center gap-4 rounded-xl bg-white p-6 transition-all hover:shadow-xl"
@click="!isLoggedIn ? redirectToLogin() : null">
<view class="relative">
<!-- 头像容器添加overflow-hidden解决边框问题 -->
<view class="overflow-hidden rounded-full p-0.5" :class="levelGradient.border">
<image :src="avatarDisplay" mode="aspectFill" alt="User Avatar"
class="h-24 w-24 border-4 border-white rounded-full" @error="onAvatarError" />
</view>
<!-- 代理标识 -->
<view v-if="isAgent" class="absolute -bottom-2 -right-2">
<view class="flex items-center justify-center rounded-full px-3 py-1 text-xs text-white font-bold shadow-sm"
:class="levelGradient.badge">
{{ levelNames[level] }}
</view>
</view>
</view>
<view class="space-y-1">
<view class="text-2xl text-gray-800 font-bold">
{{
!isLoggedIn
? "点击登录"
: mobile
? maskName(mobile)
: isWeChat
? "微信用户"
: "未绑定手机号"
}}
</view>
<!-- 手机号绑定提示 -->
<template v-if="isLoggedIn && !mobile">
<view class="cursor-pointer text-sm text-blue-500 hover:underline" @click.stop="showBindPhoneDialog">
点击绑定手机号码
</view>
</template>
<view v-if="isAgent" class="text-sm font-medium" :class="levelGradient.text">
🎖 {{ levelText[level] }}
</view>
</view>
</view>
<VipBanner v-if="isAgent && (level === 'normal' || level === '')" />
<!-- 功能菜单 -->
<view>
<view class="overflow-hidden rounded-xl bg-white shadow-sm">
<template v-if="isAgent && ['VIP', 'SVIP'].includes(level)">
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-purple-50"
@click="toVipConfig">
<view class="flex items-center gap-3">
<image src="/static/images/me/dlbgpz.png" class="h-6 w-6" alt="代理报告配置" />
<text class="text-purple-700 font-medium">
代理报告配置
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-amber-50"
@click="toVipRenewal">
<view class="flex items-center gap-3">
<image src="/static/images/me/xfhy.png" class="h-6 w-6" alt="代理会员" />
<view class="flex flex-col items-start">
<text class="text-amber-700 font-medium">
续费代理会员
</text>
<text class="text-xs text-gray-500">
有效期至 {{ formatExpiryTime(ExpiryTime) }}
</text>
</view>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
</template>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toPromote">
<view class="flex items-center gap-3">
<image src="/static/images/index/tgbg.png" class="h-6 w-6" alt="推广报告" />
<text class="text-gray-700 font-medium">
推广报告
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toInvitation">
<view class="flex items-center gap-3">
<image src="/static/images/index/yqhy.png" class="h-6 w-6" alt="邀请下级" />
<text class="text-gray-700 font-medium">
邀请下级
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toHistory">
<view class="flex items-center gap-3">
<image src="/static/images/index/wdbg.png" class="h-6 w-6" alt="我的报告" />
<text class="text-gray-700 font-medium">
我的报告
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toUserAgreement">
<view class="flex items-center gap-3">
<image src="/static/images/me/yhxy.png" class="h-6 w-6" alt="用户协议" />
<text class="text-gray-700 font-medium">
用户协议
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-blue-50"
@click="toPrivacyPolicy">
<view class="flex items-center gap-3">
<image src="/static/images/me/yszc.png" class="h-6 w-6" alt="隐私政策" />
<text class="text-gray-700 font-medium">
隐私政策
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view class="w-full flex items-center justify-between px-6 py-4 transition-colors hover:bg-blue-50"
@click="toService">
<view class="flex items-center gap-3">
<image src="/static/images/me/lxkf.png" class="h-6 w-6" alt="联系客服" />
<text class="text-gray-700 font-medium">
联系客服
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view v-if="isLoggedIn && mobile"
class="w-full flex items-center justify-between border-b border-gray-100 px-6 py-4 transition-colors hover:bg-red-50"
@click="goCancelAccountPage">
<view class="flex items-center gap-3">
<image src="/static/images/me/cancelAccount.svg" class="h-6 w-6" alt="注销账号" />
<text class="text-gray-700 font-medium">
注销账号
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
<view v-if="isLoggedIn && !isWeChat"
class="w-full flex items-center justify-between px-6 py-4 transition-colors hover:bg-red-50"
@click="handleLogout">
<view class="flex items-center gap-3">
<image src="/static/images/me/tcdl.png" class="h-6 w-6" alt="退出登录" />
<text class="text-gray-700 font-medium">
退出登录
</text>
</view>
<image src="/static/images/me/right.png" class="h-4 w-4" alt="右箭头" />
</view>
</view>
</view>
</view>
</view>
</template>
<style scoped></style>

210
src/pages/not-found.vue Normal file
View File

@@ -0,0 +1,210 @@
<script setup>
import { onMounted } from 'vue'
import { useSEO } from '@/composables/useSEO'
definePage({ layout: false })
// SEO优化
const { updateSEO } = useSEO()
onMounted(() => {
const origin = import.meta.env.VITE_SITE_ORIGIN
if (!origin)
throw new Error('缺少环境变量: VITE_SITE_ORIGIN')
const siteName = import.meta.env.VITE_SEO_SITE_NAME
if (!siteName)
throw new Error('缺少环境变量: VITE_SEO_SITE_NAME')
updateSEO({
title: `404 - 页面未找到 | ${siteName}`,
description: `抱歉,您访问的页面不存在。${siteName}专业大数据风险管控平台,提供大数据风险报告查询、婚姻状况查询、个人信用评估等服务。`,
keywords: `404, 页面未找到, ${siteName}, 大数据风险管控`,
url: `${origin}/404`,
})
})
</script>
<template>
<div class="not-found">
<div class="not-found-content">
<h1>404</h1>
<h2>页面未找到</h2>
<p>抱歉您访问的页面不存在或已被移除</p>
<div class="suggestions">
<h3>您可以尝试</h3>
<ul>
<li>
<router-link to="/">
返回首页
</router-link>
</li>
<li>
<router-link to="/help">
查看帮助中心
</router-link>
</li>
<li>
<router-link to="/service">
联系客服
</router-link>
</li>
</ul>
</div>
<div class="actions">
<router-link to="/" class="home-link">
返回首页
</router-link>
<router-link to="/help" class="help-link">
帮助中心
</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.not-found {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.not-found-content {
background: white;
padding: 60px 40px;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 600px;
width: 100%;
}
.not-found h1 {
font-size: 120px;
color: #667eea;
margin: 0 0 20px 0;
font-weight: bold;
line-height: 1;
}
.not-found h2 {
font-size: 32px;
color: #333;
margin: 0 0 20px 0;
font-weight: 600;
}
.not-found p {
font-size: 18px;
color: #666;
margin-bottom: 30px;
line-height: 1.6;
}
.suggestions {
margin: 30px 0;
text-align: left;
}
.suggestions h3 {
font-size: 20px;
color: #333;
margin-bottom: 15px;
text-align: center;
}
.suggestions ul {
list-style: none;
padding: 0;
margin: 0;
}
.suggestions li {
margin: 10px 0;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.suggestions li:last-child {
border-bottom: none;
}
.suggestions a {
color: #667eea;
text-decoration: none;
font-size: 16px;
transition: color 0.3s;
}
.suggestions a:hover {
color: #5a6fd8;
text-decoration: underline;
}
.actions {
margin-top: 40px;
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.home-link, .help-link {
display: inline-block;
padding: 15px 30px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 50px;
transition: all 0.3s ease;
min-width: 140px;
}
.home-link {
color: #fff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.home-link:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.help-link {
color: #667eea;
background: white;
border: 2px solid #667eea;
}
.help-link:hover {
background: #667eea;
color: white;
transform: translateY(-2px);
}
@media (max-width: 768px) {
.not-found-content {
padding: 40px 20px;
}
.not-found h1 {
font-size: 80px;
}
.not-found h2 {
font-size: 24px;
}
.actions {
flex-direction: column;
align-items: center;
}
.home-link, .help-link {
width: 100%;
max-width: 200px;
}
}
</style>

View File

@@ -0,0 +1,549 @@
<script setup>
import {
computed,
onBeforeMount,
onBeforeUnmount,
ref,
} from 'vue'
import { openCustomerService } from '@/composables/useCustomerService'
import { useAgentStore } from '@/stores/agentStore'
import { useUserStore } from '@/stores/userStore'
definePage({ layout: 'default' })
const orderNo = ref('')
const agentStore = useAgentStore()
const userStore = useUserStore()
// 状态变量
const isLoading = ref(true)
const paymentResult = ref(null)
const paymentType = ref('')
const paymentStatus = ref('')
const isApiError = ref(false)
const pollingInterval = ref(null)
const pollingCount = ref(0)
const maxPollingCount = 30 // 最大轮询次数
const baseInterval = 2000 // 基础轮询间隔2秒
// 计算属性
const statusText = computed(() => {
if (isApiError.value) {
return '系统繁忙'
}
switch (paymentStatus.value) {
case 'pending':
return '正在支付'
case 'failed':
return '支付失败'
case 'closed':
return '订单已关闭'
default:
return '处理中'
}
})
const statusMessage = computed(() => {
if (isApiError.value) {
return '系统正在维护或网络繁忙,请稍后再试,或联系客服确认订单状态。'
}
switch (paymentStatus.value) {
case 'pending':
return '您的订单正在支付,请稍后'
case 'failed':
return '支付未成功,您可以返回重新支付,或联系客服确认详情。'
case 'closed':
return '订单已关闭,如有疑问请联系客服。'
default:
return '系统正在处理您的订单,如有疑问请联系客服。'
}
})
// 状态图标
const getStatusIcon = computed(() => {
if (isApiError.value) {
return '⚠️'
}
return paymentStatus.value === 'pending' ? '⏳' : '❌'
})
// 状态颜色
const getStatusColor = computed(() => {
if (isApiError.value) {
return '#ff9800' // 橙色警告
}
return paymentStatus.value === 'pending' ? '#ff976a' : '#ee0a24'
})
// 环形样式类
const getRingClass = computed(() => {
if (isApiError.value) {
return 'api-error-ring'
}
return {
'pending-ring': paymentStatus.value === 'pending',
'failed-ring': paymentStatus.value === 'failed',
'closed-ring': paymentStatus.value === 'closed',
}
})
// 状态文本样式
const getStatusTextClass = computed(() => {
if (isApiError.value) {
return 'text-amber-600'
}
return {
'text-orange-500': paymentStatus.value === 'pending',
'text-red-500':
paymentStatus.value === 'failed'
|| paymentStatus.value === 'closed',
}
})
// 消息文本样式
const getMessageClass = computed(() => {
if (isApiError.value) {
return 'text-amber-800'
}
return {
'text-orange-700': paymentStatus.value === 'pending',
'text-red-700':
paymentStatus.value === 'failed'
|| paymentStatus.value === 'closed',
}
})
// 计算轮询间隔时间(渐进式增加)
const getPollingInterval = computed(() => {
// 每5次轮询增加1秒最大间隔10秒
const increment = Math.floor(pollingCount.value / 5)
return Math.min(baseInterval + increment * 1000, 10000)
})
// 检查支付状态
async function checkPaymentStatus() {
if (pollingCount.value >= maxPollingCount) {
// 超过最大轮询次数,停止轮询
stopPolling()
return
}
try {
const { data, error } = await useApiFetch(`/pay/check`)
.post({
order_no: orderNo.value,
})
.json()
if (data.value && !error.value) {
paymentResult.value = data.value.data
paymentType.value = data.value.data.type || ''
const newStatus = data.value.data.status || ''
// 状态发生变化时更新
if (paymentStatus.value !== newStatus) {
paymentStatus.value = newStatus
// 对于查询类型,如果状态是已支付或已退款,直接跳转
if (
paymentType.value === 'query'
&& (newStatus === 'paid' || newStatus === 'refunded')
) {
stopPolling()
uni.redirectTo({
url: `/pages/report-result-webview?orderNo=${encodeURIComponent(orderNo.value)}`,
})
return
}
// 如果状态不是 pending停止轮询
if (newStatus !== 'pending') {
stopPolling()
}
}
}
else {
console.error('API调用失败:', error.value)
// 不要立即停止轮询,继续尝试
}
}
catch (err) {
console.error('验证支付状态失败:', err)
// 不要立即停止轮询,继续尝试
}
finally {
pollingCount.value++
isLoading.value = false
}
}
// 开始轮询
function startPolling() {
if (pollingInterval.value)
return
pollingCount.value = 0
const poll = () => {
checkPaymentStatus()
if (
paymentStatus.value === 'pending'
&& pollingCount.value < maxPollingCount
) {
pollingInterval.value = setTimeout(poll, getPollingInterval.value)
}
}
poll()
}
// 停止轮询
function stopPolling() {
if (pollingInterval.value) {
clearTimeout(pollingInterval.value)
pollingInterval.value = null
}
}
// 在组件挂载前验证支付结果
onLoad(async (query) => {
const q = query || {}
orderNo.value = String(q.out_trade_no || q.orderNo || '')
if (!orderNo.value) {
uni.reLaunch({ url: '/pages/index' })
return
}
// 首次检查支付状态
await checkPaymentStatus()
// 如果状态是 pending开始轮询
if (paymentStatus.value === 'pending') {
startPolling()
}
})
// 组件卸载前清理轮询
onBeforeUnmount(() => {
stopPolling()
})
// 处理导航逻辑
function handleNavigation() {
if (paymentType.value === 'agent_vip') {
// 跳转到代理会员页面
uni.switchTab({ url: '/pages/agent' })
agentStore.fetchAgentStatus()
userStore.fetchUserInfo()
}
else {
// 跳转到查询结果页面
uni.redirectTo({
url: `/pages/report-result-webview?orderNo=${encodeURIComponent(orderNo.value)}`,
})
}
}
// 返回首页
function goHome() {
uni.reLaunch({ url: '/pages/index' })
}
// 联系客服
function contactService() {
openCustomerService()
}
// 暴露方法和数据供父组件或路由调用
defineExpose({
paymentResult,
paymentType,
paymentStatus,
handleNavigation,
stopPolling, // 暴露停止轮询方法
})
</script>
<template>
<div class="payment-result-container flex flex-col items-center p-6">
<!-- 加载动画验证支付结果时显示 -->
<div v-if="isLoading" class="w-full">
<div class="flex flex-col items-center justify-center py-10">
<view class="loading-spinner" />
<p class="mt-4 text-lg text-gray-600">
正在处理支付结果...
</p>
</div>
</div>
<!-- 支付结果展示 -->
<div v-else class="w-full">
<!-- 支付成功 -->
<div v-if="paymentStatus === 'paid'" class="success-result">
<div class="success-animation mb-6">
<text class="status-icon">
</text>
<div class="success-ring" />
</div>
<h1 class="mb-4 text-center text-2xl text-gray-800 font-bold">
支付成功
</h1>
<div class="payment-info mb-6 w-full rounded-lg bg-white p-6 shadow-md">
<div class="mb-4 flex justify-between">
<span class="text-gray-600">订单编号</span>
<span class="text-gray-800">{{ orderNo }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">支付类型</span>
<span class="text-gray-800">{{
paymentType === "agent_vip"
? "代理会员"
: "查询服务"
}}</span>
</div>
</div>
<div v-if="paymentType === 'agent_vip'" class="mb-4 text-center text-gray-600">
恭喜你成为高级代理会员享受更多权益
</div>
<div class="action-buttons grid grid-cols-1 gap-4">
<wd-button block type="primary" class="rounded-lg" @click="handleNavigation">
{{
paymentType === "agent_vip"
? "查看会员权益"
: "查看查询结果"
}}
</wd-button>
</div>
</div>
<!-- 退款状态 -->
<div v-else-if="paymentStatus === 'refunded'" class="refund-result">
<div v-if="paymentType === 'query'" class="success-animation mb-6">
<text class="status-icon">
</text>
<div class="success-ring" />
</div>
<div v-else class="info-animation mb-6">
<text class="status-icon">
</text>
<div class="info-ring" />
</div>
<h1 class="mb-4 text-center text-2xl text-gray-800 font-bold">
{{ paymentType === "query" ? "已处理" : "订单已退款" }}
</h1>
<div class="payment-info mb-6 w-full rounded-lg bg-white p-6 shadow-md">
<div class="mb-4 flex justify-between">
<span class="text-gray-600">订单编号</span>
<span class="text-gray-800">{{ orderNo }}</span>
</div>
<div class="mb-4 flex justify-between">
<span class="text-gray-600">支付类型</span>
<span class="text-gray-800">{{
paymentType === "agent_vip"
? "代理会员"
: "查询服务"
}}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">订单状态</span>
<span class="text-blue-600">已退款</span>
</div>
</div>
<div v-if="paymentType === 'query'" class="action-buttons grid grid-cols-1 gap-4">
<wd-button block type="primary" class="rounded-lg" @click="handleNavigation">
查看查询结果
</wd-button>
</div>
<div v-else class="message-box mb-6 rounded-lg bg-blue-50 p-4">
<p class="text-center text-blue-800">
您的代理会员费用已退款如有疑问请联系客服
</p>
</div>
<div v-if="paymentType === 'agent_vip'" class="action-buttons grid grid-cols-1 gap-4">
<wd-button block type="primary" class="rounded-lg" @click="contactService">
联系客服
</wd-button>
</div>
</div>
<!-- 其他状态待支付失败关闭 -->
<div v-else class="other-result">
<div class="info-animation mb-6">
<text class="status-icon" :style="{ color: getStatusColor }">
{{ getStatusIcon }}
</text>
<div class="info-ring" :class="getRingClass" />
</div>
<h1 class="mb-4 text-center text-2xl text-gray-800 font-bold">
{{ statusText }}
</h1>
<!-- 添加轮询状态提示 -->
<div v-if="paymentStatus === 'pending'" class="mb-4 text-center text-gray-500">
<p>正在等待支付结果请稍候...</p>
<p class="mt-1 text-sm">
已等待
{{
Math.floor(
(pollingCount * getPollingInterval) / 1000,
)
}}
</p>
</div>
<div class="payment-info mb-6 w-full rounded-lg bg-white p-6 shadow-md">
<div class="mb-4 flex justify-between">
<span class="text-gray-600">订单编号</span>
<span class="text-gray-800">{{ orderNo }}</span>
</div>
<div v-if="!isApiError" class="mb-4 flex justify-between">
<span class="text-gray-600">支付类型</span>
<span class="text-gray-800">{{
paymentType === "agent_vip"
? "代理会员"
: "查询服务"
}}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">订单状态</span>
<span :class="getStatusTextClass">{{
statusText
}}</span>
</div>
</div>
<div class="message-box mb-6 rounded-lg bg-blue-50 p-4">
<p class="text-center" :class="getMessageClass">
{{ statusMessage }}
</p>
</div>
<div class="action-buttons grid grid-cols-2 gap-4">
<wd-button block type="info" class="rounded-lg" @click="goHome">
返回首页
</wd-button>
<wd-button block type="primary" class="rounded-lg" @click="contactService">
联系客服
</wd-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.payment-result-container {
min-height: 80vh;
background-color: #f8f9fa;
}
.loading-spinner {
width: 48px;
height: 48px;
border: 4px solid #dbeafe;
border-top-color: #3b82f6;
border-radius: 9999px;
animation: spin 0.9s linear infinite;
}
.status-icon {
font-size: 56px;
line-height: 1;
z-index: 2;
}
.success-animation,
.info-animation {
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin: 2rem auto;
}
.success-ring,
.info-ring,
.pending-ring,
.failed-ring,
.closed-ring,
.api-error-ring {
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
animation: pulse 1.5s infinite;
}
.success-ring {
border: 2px solid #07c160;
}
.info-ring {
border: 2px solid #1989fa;
}
.pending-ring {
border: 2px solid #ff976a;
}
.failed-ring,
.closed-ring {
border: 2px solid #ee0a24;
}
.api-error-ring {
border: 2px solid #ff9800;
/* 橙色警告 */
}
@keyframes pulse {
0% {
transform: scale(0.95);
opacity: 0.8;
}
70% {
transform: scale(1.1);
opacity: 0.3;
}
100% {
transform: scale(0.95);
opacity: 0.8;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.success-result,
.refund-result,
.other-result {
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad(() => {
src.value = buildSitePathUrl('/app/privacyPolicy')
})
</script>
<template>
<web-view :src="src" />
</template>

225
src/pages/promote.vue Normal file
View File

@@ -0,0 +1,225 @@
<script setup>
import PriceInputPopup from '@/components/PriceInputPopup.vue'
import VipBanner from '@/components/VipBanner.vue'
definePage({ layout: 'default' })
const showPricePicker = ref(false)
const pickerProductConfig = ref(null)
const productConfig = ref(null)
const linkIdentifier = ref('')
const showQRcode = ref(false)
const promotionForm = ref(null)
const loadingConfig = ref(false)
const generating = ref(false)
const formData = ref({
productType: '',
clientPrice: null,
})
const availableReportTypes = computed(() => {
if (!productConfig.value?.length)
return []
return productConfig.value
.map(item => ({
id: item.product_id,
label: item.product_name || `产品${item.product_id}`,
value: item.product_en || '',
}))
.filter(item => !!item.value)
})
const costPrice = computed(() => {
if (!pickerProductConfig.value)
return '0.00'
let platformPricing = 0
platformPricing += pickerProductConfig.value.cost_price
if (formData.value.clientPrice > pickerProductConfig.value.p_pricing_standard) {
platformPricing += (formData.value.clientPrice - pickerProductConfig.value.p_pricing_standard) * pickerProductConfig.value.p_overpricing_ratio
}
if (pickerProductConfig.value.a_pricing_standard > platformPricing && pickerProductConfig.value.a_pricing_end > platformPricing && pickerProductConfig.value.a_overpricing_ratio > 0) {
if (formData.value.clientPrice > pickerProductConfig.value.a_pricing_standard) {
if (formData.value.clientPrice > pickerProductConfig.value.a_pricing_end) {
platformPricing += (pickerProductConfig.value.a_pricing_end - pickerProductConfig.value.a_pricing_standard) * pickerProductConfig.value.a_overpricing_ratio
}
else {
platformPricing += (formData.value.clientPrice - pickerProductConfig.value.a_pricing_standard) * pickerProductConfig.value.a_overpricing_ratio
}
}
}
return safeTruncate(platformPricing)
})
const promotionRevenue = computed(() => {
return safeTruncate(formData.value.clientPrice - costPrice.value)
})
function safeTruncate(num, decimals = 2) {
if (Number.isNaN(num) || !Number.isFinite(num))
return '0.00'
const factor = 10 ** decimals
const scaled = Math.trunc(num * factor)
return (scaled / factor).toFixed(decimals)
}
function selectProductType(reportTypeValue) {
const reportType = availableReportTypes.value.find(item => item.id === reportTypeValue || item.value === reportTypeValue)
if (!reportType)
return
formData.value.productType = reportType.value
const matchedConfig = productConfig.value?.find(item => item.product_id === reportType.id)
if (!matchedConfig) {
pickerProductConfig.value = null
formData.value.clientPrice = null
uni.showToast({ title: '该报告暂不可推广', icon: 'none' })
return
}
pickerProductConfig.value = matchedConfig
formData.value.clientPrice = matchedConfig.cost_price
}
function onConfirmType(e) {
const nextValue = e?.value?.[0] ?? e?.selectedOptions?.[0]?.value
if (nextValue)
selectProductType(nextValue)
}
function onPriceChange(price) {
formData.value.clientPrice = price
}
function getPricePayload() {
return safeTruncate(Number(formData.value.clientPrice))
}
function openPricePicker() {
if (!pickerProductConfig.value) {
uni.showToast({ title: '请先选择报告类型', icon: 'none' })
return
}
showPricePicker.value = true
}
async function getPromoteConfig() {
loadingConfig.value = true
try {
const { data, error } = await useApiFetch('/agent/product_config').get().json()
if (data.value && !error.value && data.value.code === 200) {
const list = data.value.data.AgentProductConfig || []
productConfig.value = list
const availableType = availableReportTypes.value[0]
if (availableType) {
selectProductType(availableType.value)
}
else {
pickerProductConfig.value = null
formData.value.productType = ''
formData.value.clientPrice = null
uni.showToast({ title: '暂无可推广产品', icon: 'none' })
}
return
}
uni.showToast({ title: data.value?.msg || '获取配置失败', icon: 'none' })
}
catch {
uni.showToast({ title: '获取配置失败', icon: 'none' })
}
finally {
loadingConfig.value = false
}
}
async function generatePromotionCode() {
if (generating.value)
return
try {
await promotionForm.value.validate()
}
catch {
return
}
generating.value = true
try {
const { data, error } = await useApiFetch('/agent/generating_link')
.post({ product: formData.value.productType, price: getPricePayload() })
.json()
if (data.value && !error.value && data.value.code === 200) {
linkIdentifier.value = data.value.data.link_identifier
showQRcode.value = true
}
else {
uni.showToast({ title: data.value?.msg || '生成推广码失败', icon: 'none' })
}
}
catch {
uni.showToast({ title: '生成推广码失败', icon: 'none' })
}
finally {
generating.value = false
}
}
onMounted(() => {
getPromoteConfig()
})
</script>
<template>
<view class="promote min-h-screen p-4">
<view class="card mb-4 from-orange-200 to-orange-200/80 !bg-gradient-to-b">
<view class="text-lg text-orange-500 font-bold">
直推用户查询
</view>
<view class="mt-1 text-orange-400 font-bold">
自定义价格赚取差价
</view>
<view class="mt-6 rounded-xl bg-orange-100 px-4 py-2 text-gray-600">
在下方 自定义价格 处选择报告类型设置客户查询价即可立即推广
</view>
</view>
<VipBanner />
<view class="card mb-4">
<view class="mb-2 text-xl font-semibold">
生成推广码
</view>
<wd-form ref="promotionForm" :model="formData">
<wd-cell-group border>
<wd-picker v-model="formData.productType" label="报告类型" label-width="100px" title="选择报告类型"
:columns="[availableReportTypes]" prop="productType" placeholder="请选择报告类型"
:rules="[{ required: true, message: '请选择报告类型' }]" @confirm="onConfirmType" />
<wd-input v-model="formData.clientPrice" label="客户查询价" label-width="100px" placeholder="请输入价格"
prop="clientPrice" clickable readonly suffix-icon="arrow-right"
:rules="[{ required: true, message: '请输入客户查询价' }]" @click="openPricePicker" />
</wd-cell-group>
<view class="my-2 flex items-center justify-between text-sm text-gray-500">
<view>
推广收益为 <text class="text-orange-500">
{{ promotionRevenue }}
</text>
</view>
<view>
我的成本为 <text class="text-orange-500">
{{ costPrice }}
</text>
</view>
</view>
</wd-form>
<wd-button type="primary" block :loading="generating || loadingConfig"
:disabled="loadingConfig || !pickerProductConfig" @click="generatePromotionCode">
点击立即推广
</wd-button>
</view>
<PriceInputPopup v-model:show="showPricePicker" :default-price="formData.clientPrice"
:product-config="pickerProductConfig" @change="onPriceChange" />
<QRcode v-model:show="showQRcode" :link-identifier="linkIdentifier" />
</view>
</template>
<style scoped></style>

View File

@@ -0,0 +1,58 @@
<script setup>
import { ref } from 'vue'
import InquireForm from '@/components/InquireForm.vue'
import LoginDialog from '@/components/LoginDialog.vue'
import { useRoute } from '@/composables/uni-router'
definePage({ layout: 'default' })
const route = useRoute()
const linkIdentifier = ref('')
const feature = ref('')
const featureData = ref({})
onLoad(async (query) => {
const q = query || {}
if (q.out_trade_no) {
uni.navigateTo({
url: `/pages/report-result-webview?out_trade_no=${encodeURIComponent(String(q.out_trade_no))}`,
})
return
}
await getProduct()
})
async function getProduct() {
linkIdentifier.value = route.params.linkIdentifier
const { data: agentLinkData, error: agentLinkError } = await useApiFetch(
`/agent/link?link_identifier=${linkIdentifier.value}`,
)
.get()
.json()
if (agentLinkData.value && !agentLinkError.value) {
if (agentLinkData.value.code === 200) {
feature.value = agentLinkData.value.data.product_en
featureData.value = agentLinkData.value.data
// 确保 FLXG0V4B 排在首位
if (
featureData.value.features
&& featureData.value.features.length > 0
) {
featureData.value.features.sort((a, b) => {
if (a.api_id === 'FLXG0V4B')
return -1
if (b.api_id === 'FLXG0V4B')
return 1
return 0
})
}
}
}
}
</script>
<template>
<InquireForm type="promotion" :feature="feature" :link-identifier="linkIdentifier" :feature-data="featureData" />
<LoginDialog />
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildReportUrl } = useReportWebview()
const src = ref('')
onLoad((query) => {
src.value = buildReportUrl('example', {
feature: String(query?.feature || ''),
})
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onShow } from '@dcloudio/uni-app'
import { useReportWebview } from '@/composables/useReportWebview'
import { ensureCurrentPageAccess } from '@/composables/useNavigationAuthGuard'
definePage({ layout: false })
const { buildReportUrl } = useReportWebview()
const src = ref('')
onLoad((query) => {
const q = query || {}
src.value = buildReportUrl('report', {
orderNo: String(q.orderNo || ''),
order_id: String(q.order_id || ''),
out_trade_no: String(q.out_trade_no || ''),
feature: String(q.feature || ''),
})
})
onShow(() => {
ensureCurrentPageAccess('redirect')
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildReportShareUrl } = useReportWebview()
const src = ref('')
onLoad((query = {}) => {
const linkIdentifier = String(query?.linkIdentifier || '')
src.value = buildReportShareUrl(linkIdentifier)
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,353 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute } from '@/composables/uni-router'
import useApiFetch from '@/composables/useApiFetch'
definePage({ layout: 'default', auth: true })
const route = useRoute()
const loading = ref(false)
const page = ref(1)
const pageSize = 8
const total = ref(0)
const rewardDetails = ref([])
const userInfo = ref({})
const summary = ref({})
const statistics = ref([])
// 获取收益列表
async function fetchRewardDetails() {
if (loading.value)
return
loading.value = true
const { data, error } = await useApiFetch(
`/agent/subordinate/contribution/detail?subordinate_id=${route.params.id}&page=${page.value}&page_size=${pageSize}`,
)
.get()
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
if (page.value === 1) {
// 更新用户信息
userInfo.value = {
createTime: data.value.data.create_time,
level: data.value.data.level_name || '普通',
mobile: data.value.data.mobile,
}
// 更新汇总数据
summary.value = {
totalReward: data.value.data.total_earnings,
totalContribution: data.value.data.total_contribution,
totalOrders: data.value.data.total_orders,
}
// 设置默认的统计类型
statistics.value = [
{
type: 'descendant_promotion',
amount: 0,
count: 0,
description: '推广奖励',
},
{
type: 'cost',
amount: 0,
count: 0,
description: '成本贡献',
},
{
type: 'pricing',
amount: 0,
count: 0,
description: '定价贡献',
},
{
type: 'descendant_withdraw',
amount: 0,
count: 0,
description: '提现收益',
},
{
type: 'descendant_upgrade_vip',
amount: 0,
count: 0,
description: '转化VIP奖励',
},
{
type: 'descendant_upgrade_svip',
amount: 0,
count: 0,
description: '转化SVIP奖励',
},
]
// 如果有统计数据,更新对应的值
if (data.value.data.stats) {
const stats = data.value.data.stats
// 更新推广奖励
const platformStat = statistics.value.find(s => s.type === 'descendant_promotion')
if (platformStat) {
platformStat.amount = stats.descendant_promotion_amount || 0
platformStat.count = stats.descendant_promotion_count || 0
}
// 更新成本贡献
const costStat = statistics.value.find(s => s.type === 'cost')
if (costStat) {
costStat.amount = stats.cost_amount || 0
costStat.count = stats.cost_count || 0
}
// 更新定价贡献
const pricingStat = statistics.value.find(s => s.type === 'pricing')
if (pricingStat) {
pricingStat.amount = stats.pricing_amount || 0
pricingStat.count = stats.pricing_count || 0
}
// 更新提现收益
const withdrawStat = statistics.value.find(s => s.type === 'descendant_withdraw')
if (withdrawStat) {
withdrawStat.amount = stats.descendant_withdraw_amount || 0
withdrawStat.count = stats.descendant_withdraw_count || 0
}
// 更新转化VIP奖励
const conversionVipStat = statistics.value.find(s => s.type === 'descendant_upgrade_vip')
if (conversionVipStat) {
conversionVipStat.amount = stats.descendant_upgrade_vip_amount || 0
conversionVipStat.count = stats.descendant_upgrade_vip_count || 0
}
// 更新转化SVIP奖励
const conversionSvipStat = statistics.value.find(s => s.type === 'descendant_upgrade_svip')
if (conversionSvipStat) {
conversionSvipStat.amount = stats.descendant_upgrade_svip_amount || 0
conversionSvipStat.count = stats.descendant_upgrade_svip_count || 0
}
}
rewardDetails.value = []
}
total.value = data.value.data.total || 0
// 处理列表数据
if (data.value.data.list) {
const list = data.value.data.list
rewardDetails.value = list
}
else {
rewardDetails.value = []
}
}
}
loading.value = false
}
function onPageChange({ value }) {
page.value = value
fetchRewardDetails()
}
onMounted(() => {
fetchRewardDetails()
})
// 获取收益类型样式
function getRewardTypeClass(type) {
const typeMap = {
descendant_promotion: 'bg-blue-100 text-blue-600',
cost: 'bg-green-100 text-green-600',
pricing: 'bg-purple-100 text-purple-600',
descendant_withdraw: 'bg-yellow-100 text-yellow-600',
descendant_upgrade_vip: 'bg-red-100 text-red-600',
descendant_upgrade_svip: 'bg-orange-100 text-orange-600',
}
return typeMap[type] || 'bg-gray-100 text-gray-600'
}
// 获取收益类型图标
function getRewardTypeIcon(type) {
const iconMap = {
descendant_promotion: 'gift',
cost: 'gold-coin',
pricing: 'balance-pay',
descendant_withdraw: 'cash-back-record',
descendant_upgrade_vip: 'fire',
descendant_upgrade_svip: 'fire',
}
return iconMap[type] || 'balance-o'
}
// 获取收益类型描述
function getRewardTypeDescription(type) {
const descriptionMap = {
descendant_promotion: '推广奖励',
cost: '成本贡献',
pricing: '定价贡献',
descendant_withdraw: '提现收益',
descendant_upgrade_vip: '转化VIP奖励',
descendant_upgrade_svip: '转化SVIP奖励',
}
return descriptionMap[type] || '未知类型'
}
// 格式化时间
function formatTime(timeStr) {
if (!timeStr)
return '-'
const date = new Date(timeStr)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
// 格式化金额
function formatNumber(num) {
if (!num)
return '0.00'
return Number(num).toFixed(2)
}
</script>
<template>
<div class="reward-detail">
<!-- 用户信息卡片 -->
<div class="p-4">
<div class="mb-4 rounded-xl bg-white p-5 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="text-xl text-gray-800 font-semibold">
{{ userInfo.mobile }}
</div>
<span class="rounded-full bg-blue-100 px-3 py-1 text-sm text-blue-600 font-medium">
{{ userInfo.level }}代理
</span>
</div>
</div>
<div class="mb-4 text-sm text-gray-500">
成为下级代理时间{{ formatTime(userInfo.createTime) }}
</div>
<div class="grid grid-cols-3 gap-4">
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总推广单量
</div>
<div class="text-xl text-blue-600 font-semibold">
{{ summary.totalOrders }}
</div>
</div>
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总收益
</div>
<div class="text-xl text-green-600 font-semibold">
¥{{ formatNumber(summary.totalReward) }}
</div>
</div>
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总贡献
</div>
<div class="text-xl text-purple-600 font-semibold">
¥{{ formatNumber(summary.totalContribution) }}
</div>
</div>
</div>
</div>
<!-- 贡献统计卡片 -->
<div class="mb-4 rounded-xl bg-white p-4 shadow-sm">
<div class="mb-3 text-base text-gray-800 font-medium">
贡献统计
</div>
<div class="grid grid-cols-2 gap-3">
<div
v-for="item in statistics"
:key="item.type"
class="flex items-center rounded-lg p-2"
:class="getRewardTypeClass(item.type).split(' ')[0]"
>
<wd-icon
:name="getRewardTypeIcon(item.type)"
class="mr-2 text-lg"
:class="getRewardTypeClass(item.type).split(' ')[1]"
/>
<div class="flex-1">
<div class="text-sm font-medium" :class="getRewardTypeClass(item.type).split(' ')[1]">
{{ item.description }}
</div>
<div class="mt-1 flex items-center justify-between">
<div class="text-xs text-gray-500">
{{ item.count }}
</div>
<div class="text-sm font-medium" :class="getRewardTypeClass(item.type).split(' ')[1]">
¥{{ formatNumber(item.amount) }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="mb-4 rounded-xl bg-white p-4 shadow-sm">
<!-- 贡献记录列表 -->
<div class="text-base text-gray-800 font-medium">
贡献记录
</div>
<div class="detail-scroll p-4">
<div v-if="rewardDetails.length === 0" class="py-8 text-center text-gray-500">
暂无贡献记录
</div>
<div v-for="item in rewardDetails" v-else :key="item.id" class="reward-item">
<div class="mb-3 border-b border-gray-200 pb-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<wd-icon
:name="getRewardTypeIcon(item.type)"
class="text-lg"
:class="getRewardTypeClass(item.type).split(' ')[1]"
/>
<div>
<div class="text-gray-800 font-medium">
{{ getRewardTypeDescription(item.type) }}
</div>
<div class="text-xs text-gray-500">
{{ formatTime(item.create_time) }}
</div>
</div>
</div>
<div class="text-right">
<div class="text-base font-semibold" :class="getRewardTypeClass(item.type).split(' ')[1]">
¥{{ formatNumber(item.amount) }}
</div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="py-3 text-center text-sm text-gray-400">
加载中...
</div>
</div>
<div class="px-4 pb-4">
<wd-pagination
v-model="page"
:total="total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.reward-detail {
min-height: 100vh;
background-color: #f5f5f5;
}
.reward-item {
transition: transform 0.2s;
}
.reward-item:active {
transform: scale(0.98);
}
.detail-scroll {
min-height: 50vh;
}
</style>

View File

@@ -0,0 +1,198 @@
<script setup>
import { onMounted, ref } from 'vue'
import useApiFetch from '@/composables/useApiFetch'
definePage({ layout: 'default', auth: true })
const subordinates = ref([])
const loading = ref(false)
const page = ref(1)
const pageSize = 8
// 计算统计数据
const statistics = ref({
totalSubordinates: 0,
})
// 获取下级列表
async function fetchSubordinates() {
if (loading.value)
return
loading.value = true
const { data, error } = await useApiFetch(`/agent/subordinate/list?page=${page.value}&page_size=${pageSize}`)
.get()
.json()
if (data.value && !error.value) {
if (data.value.code === 200) {
statistics.value.totalSubordinates = data.value.data.total
subordinates.value = data.value.data.list || []
}
}
loading.value = false
}
function onPageChange({ value }) {
page.value = value
fetchSubordinates()
}
// 格式化金额
function formatNumber(num) {
if (!num)
return '0.00'
return Number(num).toFixed(2)
}
// 获取等级标签样式
function getLevelClass(level) {
switch (level) {
case 'SVIP':
return 'bg-purple-100 text-purple-600'
case 'VIP':
return 'bg-blue-100 text-blue-600'
default:
return 'bg-gray-100 text-gray-600'
}
}
// 查看详情
function viewDetail(item) {
uni.navigateTo({
url: `/pages/subordinate-detail?id=${encodeURIComponent(String(item.id))}`,
})
}
onMounted(() => {
fetchSubordinates()
})
</script>
<template>
<div class="subordinate-list">
<!-- 顶部统计卡片 -->
<div class="p-4 pb-0">
<div class="rounded-xl bg-white p-4 shadow-sm">
<div class="flex items-center justify-center">
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
下级总数
</div>
<div class="text-2xl text-blue-600 font-semibold">
{{ statistics.totalSubordinates }}
</div>
</div>
</div>
</div>
</div>
<div class="subordinate-scroll p-4">
<div v-for="(item, index) in subordinates" :key="item.id" class="subordinate-item">
<div class="mb-4 flex flex-col rounded-xl bg-white p-5 shadow-sm">
<!-- 顶部信息 -->
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-3">
<div
class="h-6 w-6 flex items-center justify-center rounded-full bg-blue-100 text-sm text-blue-600 font-medium"
>
{{ index + 1 }}
</div>
<div class="text-xl text-gray-800 font-semibold">
{{ item.mobile }}
</div>
<span class="rounded-full px-3 py-1 text-sm font-medium" :class="[getLevelClass(item.level)]">
{{ item.level ? item.level : '普通' }}代理
</span>
</div>
</div>
<!-- 加入时间 -->
<div class="mb-5 text-sm text-gray-500">
成为下级代理时间{{ item.create_time }}
</div>
<!-- 数据统计 -->
<div class="grid grid-cols-3 mb-5 gap-6">
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总推广单量
</div>
<div class="text-xl text-blue-600 font-semibold">
{{ item.total_orders }}
</div>
</div>
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总收益
</div>
<div class="text-xl text-green-600 font-semibold">
¥{{ formatNumber(item.total_earnings) }}
</div>
</div>
<div class="text-center">
<div class="mb-1 text-sm text-gray-500">
总贡献
</div>
<div class="text-xl text-purple-600 font-semibold">
¥{{ formatNumber(item.total_contribution) }}
</div>
</div>
</div>
<!-- 查看详情按钮 -->
<div class="flex justify-end">
<button
class="inline-flex items-center rounded-full from-blue-500 to-blue-400 bg-gradient-to-r px-4 py-2 text-sm text-white shadow-sm transition-all duration-200 hover:shadow-md"
@click="viewDetail(item)"
>
<wd-icon name="view" custom-class="mr-1.5" />
查看详情
</button>
</div>
</div>
</div>
<div v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</div>
<div v-else-if="!subordinates.length" class="py-4 text-center text-sm text-gray-400">
暂无下级代理
</div>
</div>
<div class="px-4 pb-4">
<wd-pagination
v-model="page"
:total="statistics.totalSubordinates"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
</div>
</div>
</template>
<style scoped>
.subordinate-list {
min-height: 100vh;
background-color: #f5f5f5;
}
.subordinate-scroll {
height: calc(100vh - 180px);
}
.subordinate-item {
transition: transform 0.2s;
}
.subordinate-item:active {
transform: scale(0.98);
}
button {
transition: all 0.2s ease;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
</style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useReportWebview } from '@/composables/useReportWebview'
definePage({ layout: false })
const { buildSitePathUrl } = useReportWebview()
const src = ref('')
onLoad(() => {
src.value = buildSitePathUrl('/app/userAgreement')
})
</script>
<template>
<web-view :src="src" />
</template>

View File

@@ -0,0 +1,213 @@
<script setup>
definePage({ layout: 'default', auth: true })
// 状态映射配置
const statusConfig = {
1: {
chinese: '处理中',
color: {
bg: 'bg-yellow-100',
text: 'text-yellow-800',
dot: 'bg-yellow-500',
amount: 'text-yellow-500',
},
},
2: {
chinese: '提现成功',
color: {
bg: 'bg-green-100',
text: 'text-green-800',
dot: 'bg-green-500',
amount: 'text-green-500',
},
},
3: {
chinese: '提现失败',
color: {
bg: 'bg-red-100',
text: 'text-red-800',
dot: 'bg-red-500',
amount: 'text-red-500',
},
},
}
const page = ref(1)
const pageSize = ref(10)
const data = ref({
total: 0,
list: [],
})
const loading = ref(false)
const pageCount = computed(() => {
const total = Number(data.value.total || 0)
return total > 0 ? Math.ceil(total / pageSize.value) : 1
})
// 账户脱敏处理
function maskName(name) {
if (!name || typeof name !== 'string')
return ''
if (name.length <= 7)
return name
return `${name.substring(0, 3)}****${name.substring(7)}`
}
// 银行卡号脱敏处理
function maskBankCard(cardNo) {
if (!cardNo || typeof cardNo !== 'string')
return ''
const card = cardNo.replace(/\s/g, '')
if (card.length <= 8)
return card
return `${card.substring(0, 4)} **** **** ${card.substring(card.length - 4)}`
}
// 状态转中文
function statusToChinese(status) {
return statusConfig[status]?.chinese || '未知状态'
}
// 获取状态样式
function getStatusStyle(status) {
const config = statusConfig[status] || {}
return `${config.color?.bg || 'bg-gray-100'} ${
config.color?.text || 'text-gray-800'
}`
}
// 获取小圆点颜色
function getDotColor(status) {
return statusConfig[status]?.color.dot || 'bg-gray-500'
}
// 获取金额颜色
function getAmountColor(status) {
return statusConfig[status]?.color.amount || 'text-gray-500'
}
// 获取当前页数据
async function getData() {
try {
loading.value = true
const { data: res, error } = await useApiFetch(
`/agent/withdrawal?page=${page.value}&page_size=${pageSize.value}`,
)
.get()
.json()
if (res.value?.code === 200 && !error.value)
data.value = res.value.data
}
finally {
loading.value = false
}
}
function onPageChange({ value }) {
page.value = value
getData()
}
// 初始化加载
onMounted(async () => {
page.value = 1
await getData()
})
</script>
<template>
<view class="min-h-screen bg-gray-50">
<view class="withdraw-content">
<view
v-for="(item, index) in data.list"
:key="index"
class="mx-4 my-2 rounded-lg bg-white p-4 shadow-sm"
>
<view class="mb-2 flex items-center justify-between">
<text class="text-sm text-gray-500">
{{
item.create_time || "-"
}}
</text>
<text class="font-bold" :class="getAmountColor(item.status)">
{{ item.amount.toFixed(2) }}
</text>
</view>
<view class="mb-2 flex items-center">
<text
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium"
:class="getStatusStyle(item.status)"
>
<text
class="mr-1 h-2 w-2 rounded-full"
:class="getDotColor(item.status)"
/>
{{ statusToChinese(item.status) }}
</text>
</view>
<view class="text-xs text-gray-500">
<view v-if="item.withdraw_type === 1 && item.payee_account">
收款账户{{ maskName(item.payee_account) }}
</view>
<view v-if="item.withdraw_type === 2">
<view v-if="item.bank_card_no">
银行卡号{{ maskBankCard(item.bank_card_no) }}
</view>
<view v-if="item.bank_name">
开户支行{{ item.bank_name }}
</view>
<view v-if="item.payee_name">
收款人{{ item.payee_name }}
</view>
</view>
<view v-if="item.remark">
备注{{ item.remark }}
</view>
</view>
</view>
<view v-if="loading" class="py-4 text-center text-sm text-gray-400">
加载中...
</view>
<view v-else-if="!data.list.length" class="py-10 text-center text-sm text-gray-400">
暂无提现记录
</view>
</view>
<view class="pagination-wrap">
<wd-pagination
v-model="page"
:total="data.total"
:page-size="pageSize"
show-icon
show-message
@change="onPageChange"
/>
<text class="mt-1 block text-center text-xs text-gray-400">
{{ pageCount }}
</text>
</view>
</view>
</template>
<style scoped>
/* 保持原有样式不变 */
.list-enter-active {
transition: all 0.3s ease;
}
.list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.withdraw-content {
min-height: calc(100vh - 180px);
}
.pagination-wrap {
position: sticky;
bottom: 0;
z-index: 10;
border-top: 1px solid #f1f5f9;
background: #fff;
padding: 8px 12px 12px;
}
</style>

556
src/pages/withdraw.vue Normal file
View File

@@ -0,0 +1,556 @@
<script setup>
import { computed, onBeforeMount, ref } from 'vue'
import RealNameAuthDialog from '@/components/RealNameAuthDialog.vue'
definePage({ layout: 'default', auth: true })
const agentStore = useAgentStore()
const dialogStore = useDialogStore()
function toast(title) {
uni.showToast({ title, icon: 'none' })
}
// ========== 提现方式配置开关 ==========
// 可以通过环境变量控制
// 配置选项(建议其中一种):
// - 'alipay'
// - 'bankcard'
// - 'both'
// - 'alipay,bankcard'
// - JSON 数组字符串: ["alipay","bankcard"]
const WITHDRAW_CONFIG = import.meta.env.VITE_WITHDRAW_METHODS
if (!WITHDRAW_CONFIG) {
throw new Error('缺少环境变量: VITE_WITHDRAW_METHODS')
}
// 解析配置:支持字符串和数组格式
function getWithdrawMethods() {
if (Array.isArray(WITHDRAW_CONFIG))
return WITHDRAW_CONFIG
if (typeof WITHDRAW_CONFIG === 'string') {
// 支持 JSON 数组字符串
if (WITHDRAW_CONFIG.trim().startsWith('[')) {
const parsed = JSON.parse(WITHDRAW_CONFIG)
if (Array.isArray(parsed))
return parsed.map(String)
}
if (WITHDRAW_CONFIG === 'alipay')
return ['alipay']
if (WITHDRAW_CONFIG === 'bankcard')
return ['bankcard']
if (WITHDRAW_CONFIG === 'both')
return ['alipay', 'bankcard']
// 支持逗号分隔的字符串: "alipay,bankcard" 或 "alipay" 或 "bankcard"
return WITHDRAW_CONFIG.split(',').map(s => s.trim()).filter(Boolean)
}
throw new Error(`VITE_WITHDRAW_METHODS 无法解析: ${WITHDRAW_CONFIG}`)
}
const enabledMethods = getWithdrawMethods()
const showAlipay = enabledMethods.includes('alipay')
const showBankcard = enabledMethods.includes('bankcard')
const showTabs = showAlipay && showBankcard // 只有两种都显示时才显示 tabs
// 标签页管理
function getInitialTab() {
// 如果只显示一种方式,直接设置为该方式
if (showAlipay && !showBankcard)
return 'alipay'
if (showBankcard && !showAlipay)
return 'bankcard'
// 如果两种都显示,默认支付宝
return 'alipay'
}
const activeTab = ref(getInitialTab())
// 状态管理
const status = ref(null)
const failMsg = ref('')
const isSubmitting = ref(false)
const showStatusPopup = ref(false)
const showTaxConfirmPopup = ref(false)
// 税务
const taxFreeAmount = ref(0)
const usedExemptionAmount = ref(0)
const remainingExemptionAmount = ref(0)
const taxRate = ref(0)
// 共用数据
const amount = ref(0)
const availableAmount = ref(null)
// 计算扣税金额和实际到账金额
const taxAmount = computed(() => {
if (!amount.value)
return 0
const withdrawAmount = Number(amount.value)
return withdrawAmount * 0.06
})
const actualAmount = computed(() => {
if (!amount.value)
return 0
return Number(amount.value) - taxAmount.value
})
// 样式配置
const statusButtonColor = {
1: '#dbeafe',
2: '#10b981',
3: '#ef4444',
}
const statusMessages = {
1: activeTab.value === 'alipay' ? '提现申请处理中,请稍后再查询结果' : '提现申请已提交,等待管理员审核',
2: '提现成功',
3: '提现失败',
}
// 表单数据 - 支付宝
const alipayAccount = ref('')
const alipayRealName = ref('')
// 表单数据 - 银行卡
const bankCardNo = ref('')
const bankName = ref('')
const bankCardInfo = ref({
bank_card_no: '',
bank_name: '',
payee_name: '',
id_card: '',
})
// 标签页切换
function onTabChange(name) {
activeTab.value = name
if (name === 'bankcard') {
loadBankCardInfo()
}
resetForm()
}
// 加载银行卡信息
async function loadBankCardInfo() {
try {
const { data, error } = await useApiFetch('/agent/withdrawal/bank-card/info')
.get()
.json()
if (data.value?.code === 200 && !error.value) {
bankCardInfo.value = data.value.data
// 自动填充历史银行卡信息
if (data.value.data.bank_card_no) {
bankCardNo.value = data.value.data.bank_card_no
}
if (data.value.data.bank_name) {
bankName.value = data.value.data.bank_name
}
}
}
catch (err) {
console.error('加载银行卡信息失败', err)
}
}
// 格式化身份证号显示前6位和后4位
function formatIdCard(idCard) {
if (!idCard)
return ''
if (idCard.length <= 10)
return idCard
return `${idCard.substring(0, 6)}****${idCard.substring(idCard.length - 4)}`
}
async function getData() {
const { data: res, error } = await useApiFetch('/agent/revenue')
.get()
.json()
if (res.value?.code === 200 && !error.value) {
availableAmount.value = res.value.data.balance
}
}
onBeforeMount(() => {
getData()
getTax()
// 根据配置加载对应的数据
if (showBankcard && (activeTab.value === 'bankcard' || !showTabs)) {
loadBankCardInfo()
}
})
function validateForm() {
if (activeTab.value === 'alipay') {
// 支付宝提现验证
if (!alipayRealName.value.trim()) {
toast('请输入账户实名姓名')
return false
}
if (!/^[\u4E00-\u9FA5]{2,4}$/.test(alipayRealName.value)) {
toast('请输入2-4位中文姓名')
return false
}
if (!alipayAccount.value.trim()) {
toast('请输入支付宝账号')
return false
}
}
else {
// 银行卡提现验证
if (!bankCardNo.value.trim()) {
toast('请输入银行卡号')
return false
}
const cardNo = String(bankCardNo.value).replace(/\s/g, '')
if (!/^\d{16,19}$/.test(cardNo)) {
toast('银行卡号格式不正确请输入16-19位数字')
return false
}
if (!bankName.value.trim()) {
toast('请输入开户支行')
return false
}
}
const amountNum = Number(amount.value)
if (!amount.value || Number.isNaN(amountNum)) {
toast('请输入有效金额')
return false
}
if (amountNum < 50) {
toast('提现金额不能低于50元')
return false
}
if (amountNum > availableAmount.value) {
toast('超过可提现金额')
return false
}
return true
}
// 打开实名认证对话框
function openRealNameAuth() {
dialogStore.openRealNameAuth()
}
// 获取税务
async function getTax() {
const { data, error } = await useApiFetch('/agent/withdrawal/tax/exemption')
.get()
.json()
if (data.value?.code === 200 && !error.value) {
taxFreeAmount.value = data.value.data.total_exemption_amount
usedExemptionAmount.value = data.value.data.used_exemption_amount
remainingExemptionAmount.value = data.value.data.remaining_exemption_amount
taxRate.value = data.value.data.tax_rate
}
}
async function handleSubmit() {
// 检查实名认证状态
if (!agentStore.isRealName) {
toast('请先完成实名认证')
openRealNameAuth()
return
}
// 先进行表单验证
if (!validateForm())
return
// 显示税务确认弹窗
showTaxConfirmPopup.value = true
}
// 确认提现
async function confirmWithdraw() {
isSubmitting.value = true
try {
let apiUrl, requestData
if (activeTab.value === 'alipay') {
// 支付宝提现
apiUrl = '/agent/withdrawal'
requestData = {
payee_account: alipayAccount.value,
amount: amount.value,
payee_name: alipayRealName.value,
}
}
else {
// 银行卡提现
apiUrl = '/agent/withdrawal/bank-card'
requestData = {
bank_card_no: String(bankCardNo.value).replace(/\s/g, ''),
bank_name: bankName.value,
amount: amount.value,
}
}
const { data } = await useApiFetch(apiUrl)
.post(requestData)
.json()
if (data.value?.code === 200) {
status.value = data.value.data.status
showTaxConfirmPopup.value = false
showStatusPopup.value = true
if (status.value === 3) {
failMsg.value = data.value.data.fail_msg || '提现失败'
}
getData()
}
else {
toast(data.value?.msg || '提现失败')
}
}
catch (err) {
toast('提现失败,请重试')
console.error('提现失败', err)
}
finally {
isSubmitting.value = false
}
}
// 弹窗操作
function handlePopupAction() {
if (status.value === 3) {
showStatusPopup.value = false
resetForm()
}
else {
showStatusPopup.value = false
if (status.value === 2)
resetPage()
}
}
// 填充最大金额
function fillMaxAmount() {
amount.value = availableAmount.value
}
// 重置页面
function resetForm() {
status.value = null
if (activeTab.value === 'alipay') {
alipayAccount.value = ''
alipayRealName.value = ''
}
else {
// 银行卡提现不清空,保留历史信息
// bankCardNo.value = "";
// bankName.value = "";
}
amount.value = ''
}
function resetPage() {
resetForm()
// 可以跳转到提现记录页面
}
</script>
<template>
<view class="withdraw-page min-h-screen from-blue-50/30 to-gray-50 bg-gradient-to-b p-4">
<view v-if="!agentStore.isRealName" class="mb-4 rounded-xl bg-amber-50 p-5 shadow">
<view class="mb-2 flex items-center text-amber-700">
<text class="mr-2">
</text>
<text class="text-lg font-bold">
未完成实名认证
</text>
</view>
<text class="mb-4 block text-sm text-amber-700/90">
根据相关规定提现功能需要完成实名认证后才能使用提现金额将转入您实名认证的账户中
</text>
<wd-button block type="warning" @click="openRealNameAuth">
立即实名认证
</wd-button>
</view>
<view class="rounded-xl bg-white p-4 shadow">
<view v-if="showTabs" class="mb-4 flex gap-2">
<wd-button size="small" :type="activeTab === 'alipay' ? 'primary' : 'info'" @click="onTabChange('alipay')">
支付宝提现
</wd-button>
<wd-button size="small" :type="activeTab === 'bankcard' ? 'primary' : 'info'" @click="onTabChange('bankcard')">
银行卡提现
</wd-button>
</view>
<view v-if="(activeTab === 'alipay' && showAlipay) || (showAlipay && !showBankcard)" class="mb-4 rounded-xl bg-blue-50/60 p-4">
<text class="mb-3 block text-base text-blue-700 font-semibold">
支付宝提现
</text>
<wd-input v-model="alipayAccount" label="支付宝账号" clearable placeholder="请输入支付宝账号" />
<text class="mb-3 mt-1 block text-xs text-gray-500">
可填写支付宝账户绑定的手机号
</text>
<wd-input v-model="alipayRealName" label="实名姓名" clearable placeholder="请输入支付宝认证姓名" />
<text class="mt-1 block text-xs text-gray-500">
请填写支付宝账户认证的真实姓名
</text>
</view>
<view v-if="(activeTab === 'bankcard' && showBankcard) || (showBankcard && !showAlipay)" class="mb-4 rounded-xl bg-blue-50/60 p-4">
<text class="mb-3 block text-base text-blue-700 font-semibold">
银行卡提现
</text>
<view v-if="bankCardInfo.payee_name" class="mb-3 rounded-lg bg-blue-100/70 p-3 text-sm text-gray-700">
<text class="mb-1 block">
姓名{{ bankCardInfo.payee_name }}
</text>
<text class="block">
身份证号{{ formatIdCard(bankCardInfo.id_card) }}
</text>
<text class="mt-2 block text-xs text-amber-600">
提示银行卡信息需与实名认证信息一致
</text>
</view>
<wd-input v-model="bankCardNo" label="银行卡号" clearable type="number" placeholder="请输入银行卡号16-19位" />
<text class="mb-3 mt-1 block text-xs text-gray-500">
请输入16-19位银行卡号
</text>
<wd-input v-model="bankName" label="开户支行" clearable placeholder="请输入开户支行" />
<text class="mt-1 block text-xs text-gray-500">
例如中国工商银行XX支行
</text>
</view>
<view class="mb-2 text-sm text-gray-700">
提现金额
</view>
<view class="mb-3 flex items-center gap-2">
<wd-input v-model.number="amount" class="flex-1" type="number" placeholder="请输入提现金额" />
<wd-button size="small" type="primary" plain @click="fillMaxAmount">
全部提现
</wd-button>
</view>
<text class="mb-4 block text-sm text-gray-600">
可提现金额<text class="text-blue-600 font-semibold">
¥{{ availableAmount || 0 }}
</text>
</text>
<view class="mb-4 rounded-xl bg-blue-50 p-3">
<text class="mb-1 block text-sm text-blue-700 font-medium">
提现须知
</text>
<text class="block text-xs text-gray-600">
· 每日限提现1次最低50元
</text>
<text class="block text-xs text-gray-600">
· 提现收取6%税收
</text>
<text class="block text-xs text-gray-600">
· {{ activeTab === 'alipay' ? '到账时间24小时内' : '到账时间:管理员审核后手动转账' }}
</text>
</view>
<view class="mb-4 rounded-xl bg-amber-50 p-3">
<text class="mb-1 block text-sm text-amber-700 font-medium">
税收说明
</text>
<text class="block text-xs text-gray-600">
根据相关规定提现时将统一收取6%的税收
</text>
<text class="block text-xs text-amber-600">
税率标准统一按6%收取
</text>
<text class="block text-xs text-amber-600">
适用范围所有提现金额
</text>
</view>
<wd-button block type="primary" :loading="isSubmitting" @click="handleSubmit">
立即提现
</wd-button>
</view>
<view v-if="showTaxConfirmPopup" class="modal-mask">
<view class="modal-card">
<text class="mb-2 block text-center text-lg font-semibold">
提现确认
</text>
<text class="mb-4 block text-center text-sm text-gray-500">
请确认以下提现信息
</text>
<view class="mb-4 rounded-lg bg-gray-50 p-3 text-sm">
<view class="mb-1 flex justify-between">
<text>提现金额</text><text>¥{{ amount }}</text>
</view>
<view class="mb-1 flex justify-between">
<text>税收</text><text class="text-red-500">
-¥{{ taxAmount.toFixed(2) }}
</text>
</view>
<view class="flex justify-between border-t pt-2">
<text>实际到账</text><text class="text-blue-600 font-semibold">
¥{{ actualAmount.toFixed(2) }}
</text>
</view>
</view>
<view class="flex gap-2">
<wd-button plain block @click="showTaxConfirmPopup = false">
取消
</wd-button>
<wd-button block type="primary" :loading="isSubmitting" @click="confirmWithdraw">
确认提现
</wd-button>
</view>
</view>
</view>
<view v-if="showStatusPopup" class="modal-mask">
<view class="modal-card">
<text class="mb-2 block text-center text-3xl">
{{ status === 1 ? '⏳' : status === 2 ? '✅' : '❌' }}
</text>
<text class="mb-1 block text-center text-lg font-semibold" :class="status === 2 ? 'text-green-600' : status === 3 ? 'text-red-600' : 'text-blue-600'">
{{ statusMessages[status] }}
</text>
<text v-if="status === 2" class="mb-2 block text-center text-sm text-gray-600">
{{ activeTab === 'alipay' ? `已向 ${alipayAccount} 转账` : '提现申请已通过管理员将手动转账' }}
</text>
<text v-if="status === 3" class="mb-2 block text-center text-sm text-red-500">
{{ failMsg }}
</text>
<view v-if="status === 1" class="mb-3 mt-2 h-2 w-full overflow-hidden rounded-full bg-blue-100">
<view class="h-full w-3/5 rounded-full bg-blue-500" />
</view>
<wd-button block :custom-style="`background:${statusButtonColor[status]};color:${status === 1 ? '#2563eb' : '#fff'}`" @click="handlePopupAction">
{{ status === 1 ? '知道了' : status === 2 ? '完成' : '重新提现' }}
</wd-button>
</view>
</view>
</view>
<RealNameAuthDialog />
</template>
<style scoped>
.modal-mask {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
padding: 20px;
}
.modal-card {
width: 100%;
max-width: 360px;
border-radius: 16px;
background: #fff;
padding: 20px;
}
</style>

6
src/shims.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
export {}
declare module 'vue' {
type Hooks = App.AppInstance & Page.PageInstance
interface ComponentCustomOptions extends Hooks {}
}

26
src/static/base.css Normal file
View File

@@ -0,0 +1,26 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
html {
margin: auto !important;
@apply max-w-lg;
/* 确保在缩放时保持响应式 */
min-width: 320px;
}
body {
background-color: #f8f8f8;
min-height: 100vh;
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;
}

220
src/static/colors.css Normal file
View File

@@ -0,0 +1,220 @@
/*
* 统一颜色变量管理文件
* 用于规范化项目中的所有颜色使用
* 支持主题切换和颜色统一管理
*/
:root {
/* ===== 主题色系 ===== */
--color-primary: #5d7eeb;
--color-primary-50: #f0f3ff;
--color-primary-100: #e1e8ff;
--color-primary-200: #c3d1ff;
--color-primary-300: #a5baff;
--color-primary-400: #87a3ff;
--color-primary-500: #5d7eeb;
--color-primary-600: #4a63bc;
--color-primary-700: #38488d;
--color-primary-800: #252d5e;
--color-primary-900: #13122f;
/* 主题色透明度变体 */
--color-primary-light: rgba(93, 126, 235, 0.1);
--color-primary-medium: rgba(93, 126, 235, 0.15);
--color-primary-dark: rgba(93, 126, 235, 0.8);
/* ===== 语义化颜色 ===== */
--color-success: #07c160;
--color-success-50: #f0f9f0;
--color-success-100: #e1f5e1;
--color-success-200: #c3ebc3;
--color-success-300: #a5e1a5;
--color-success-400: #87d787;
--color-success-500: #07c160;
--color-success-600: #059a4c;
--color-success-700: #047338;
--color-success-800: #024c24;
--color-success-900: #012510;
--color-success-light: rgba(7, 193, 96, 0.1);
--color-success-dark: rgba(7, 193, 96, 0.8);
--color-warning: #ff976a;
--color-warning-50: #fff5f0;
--color-warning-100: #ffebe1;
--color-warning-200: #ffd7c3;
--color-warning-300: #ffc3a5;
--color-warning-400: #ffaf87;
--color-warning-500: #ff976a;
--color-warning-600: #cc7955;
--color-warning-700: #995b40;
--color-warning-800: #663d2a;
--color-warning-900: #331f15;
--color-warning-light: rgba(255, 151, 106, 0.1);
--color-warning-dark: rgba(255, 151, 106, 0.8);
--color-danger: #ee0a24;
--color-danger-light: rgba(238, 10, 36, 0.1);
--color-danger-dark: rgba(238, 10, 36, 0.8);
--color-info: #1989fa;
--color-info-light: rgba(25, 137, 250, 0.1);
--color-info-dark: rgba(25, 137, 250, 0.8);
/* ===== 中性色系 ===== */
--color-gray-50: #fafafa;
--color-gray-100: #f5f5f5;
--color-gray-200: #e5e5e5;
--color-gray-300: #d4d4d4;
--color-gray-400: #a3a3a3;
--color-gray-500: #737373;
--color-gray-600: #525252;
--color-gray-700: #404040;
--color-gray-800: #262626;
--color-gray-900: #171717;
/* ===== 文本颜色 ===== */
--color-text-primary: #323233;
--color-text-secondary: #646566;
--color-text-tertiary: #969799;
--color-text-quaternary: #c8c9cc;
--color-text-disabled: #c8c9cc;
--color-text-white: #ffffff;
/* ===== 背景颜色 ===== */
--color-bg-primary: #ffffff;
--color-bg-secondary: #fafafa;
--color-bg-tertiary: #f8f8f8;
--color-bg-quaternary: #f2f3f5;
--color-bg-overlay: rgba(0, 0, 0, 0.5);
--color-bg-mask: rgba(0, 0, 0, 0.8);
/* ===== 边框颜色 ===== */
--color-border-primary: #ebedf0;
--color-border-secondary: #f2f3f5;
--color-border-tertiary: #dcdee0;
--color-border-focus: var(--color-primary);
/* ===== 状态颜色 ===== */
--color-active: #f2f3f5;
--color-hover: rgba(0, 0, 0, 0.05);
--color-focus: var(--color-primary-light);
/* ===== 阴影颜色 ===== */
--color-shadow-light: rgba(0, 0, 0, 0.1);
--color-shadow-medium: rgba(0, 0, 0, 0.15);
--color-shadow-dark: rgba(0, 0, 0, 0.2);
/* ===== 业务特定颜色 ===== */
--color-service-personal: #5d7eeb; /* 个人大数据 */
--color-service-company: #6b9df9; /* 小微企业 */
--color-service-loan: #e1a0e4; /* 贷前风险 */
/* ===== 渐变色彩 ===== */
--gradient-primary: linear-gradient(
135deg,
var(--color-primary-400),
var(--color-primary-600)
);
--gradient-success: linear-gradient(
135deg,
var(--color-success),
var(--color-success-dark)
);
--gradient-warning: linear-gradient(
135deg,
var(--color-warning),
var(--color-warning-dark)
);
--gradient-danger: linear-gradient(
135deg,
var(--color-danger),
var(--color-danger-dark)
);
/* ===== Vant 兼容变量(用于历史样式平滑迁移) ===== */
--van-theme-primary: var(--color-primary);
--van-theme-primary-dark: var(--color-primary-700);
--van-theme-primary-light: var(--color-primary-light);
--van-text-color: var(--color-text-primary);
--van-text-color-2: var(--color-text-secondary);
--van-text-color-3: var(--color-text-tertiary);
--van-border-color: var(--color-border-primary);
}
/* ===== 暗色主题支持 ===== */
@media (prefers-color-scheme: dark) {
:root {
--color-text-primary: #ffffff;
--color-text-secondary: #e5e5e5;
--color-text-tertiary: #a3a3a3;
--color-text-quaternary: #737373;
--color-bg-primary: #1a1a1a;
--color-bg-secondary: #262626;
--color-bg-tertiary: #404040;
--color-bg-quaternary: #525252;
--color-border-primary: #404040;
--color-border-secondary: #525252;
--color-border-tertiary: #737373;
}
}
/* ===== 工具类 ===== */
.text-primary {
color: var(--color-primary) !important;
}
.text-success {
color: var(--color-success) !important;
}
.text-warning {
color: var(--color-warning) !important;
}
.text-danger {
color: var(--color-danger) !important;
}
.text-info {
color: var(--color-info) !important;
}
.bg-primary {
background-color: var(--color-primary) !important;
}
.bg-success {
background-color: var(--color-success) !important;
}
.bg-warning {
background-color: var(--color-warning) !important;
}
.bg-danger {
background-color: var(--color-danger) !important;
}
.bg-info {
background-color: var(--color-info) !important;
}
.border-primary {
border-color: var(--color-primary) !important;
}
.border-success {
border-color: var(--color-success) !important;
}
.border-warning {
border-color: var(--color-warning) !important;
}
.border-danger {
border-color: var(--color-danger) !important;
}
.border-info {
border-color: var(--color-info) !important;
}
/* ===== 响应式颜色工具类 ===== */
@media (max-width: 768px) {
.text-primary-mobile {
color: var(--color-primary) !important;
}
.bg-primary-mobile {
background-color: var(--color-primary) !important;
}
}

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1776498216582" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4382" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1023.795 853.64v6.348a163.807 163.807 0 0 1-163.807 163.807h-696.18A163.807 163.807 0 0 1 0 859.988v-696.18A163.807 163.807 0 0 1 163.807 0h696.181a163.807 163.807 0 0 1 163.807 163.807V853.64z" fill="#009FE9" p-id="4383"></path><path d="M844.836 648.267c-40.952-14.333-95.623-34.809-156.846-57.128a949.058 949.058 0 0 0 90.094-222.573H573.325V307.14h245.711v-43.41l-245.71 2.458V143.33H472.173c-18.223 0-21.704 20.476-21.704 20.476v102.38H204.759v40.952h245.71v61.427H245.712v40.952h409.518a805.522 805.522 0 0 1-64.909 148.246c-128.384-42.795-266.186-77.604-354.233-55.08a213.564 213.564 0 0 0-112.003 63.27c-95.418 116.917-26.21 294.034 175.274 294.034 119.989 0 236.087-67.366 325.771-177.73 134.322 65.932 398.666 176.297 398.666 176.297V701.3s-32.352-4.095-178.96-53.033z m-563.702 144.97c-158.893 0-204.759-124.699-126.336-194.112a191.86 191.86 0 0 1 90.913-46.276c93.575-10.238 189.811 35.629 293.624 86.614-74.941 94.598-166.674 153.774-258.2 153.774z" fill="#FFFFFF" p-id="4384"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,75 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024" height="1024px" width="1024px">
<title>空空如也</title>
<defs>
<rect rx="22.1405405" height="1024" width="1024" y="0" x="0" id="path-1"></rect>
<linearGradient id="linearGradient-3" y2="64.8840762%" x2="50%" y1="-33.7184979%" x1="115.913479%">
<stop offset="0%" stop-color="#6CADFF"></stop>
<stop offset="100%" stop-opacity="0" stop-color="#FFFFFF"></stop>
</linearGradient>
<linearGradient id="linearGradient-4" y2="100%" x2="70.4980572%" y1="-20.569195%" x1="10.5031837%">
<stop offset="0%" stop-color="#6CADFF"></stop>
<stop offset="100%" stop-opacity="0" stop-color="#FFFFFF"></stop>
</linearGradient>
<linearGradient id="linearGradient-5" y2="104.73608%" x2="38.801584%" y1="-97.78046%" x1="100.191761%">
<stop offset="0%" stop-color="#6CADFF"></stop>
<stop offset="100%" stop-color="#FFFFFF"></stop>
</linearGradient>
<linearGradient id="linearGradient-6" y2="100%" x2="50%" y1="-27.9013949%" x1="50%">
<stop offset="0%" stop-color="#6CADFF"></stop>
<stop offset="100%" stop-opacity="0" stop-color="#FFFFFF"></stop>
</linearGradient>
<linearGradient id="linearGradient-7" y2="100%" x2="50%" y1="-27.9013949%" x1="50%">
<stop offset="0%" stop-color="#6CADFF"></stop>
<stop offset="100%" stop-opacity="0" stop-color="#FFFFFF"></stop>
</linearGradient>
<linearGradient id="linearGradient-8" y2="100%" x2="50%" y1="-221.1569%" x1="50%">
<stop offset="0%" stop-color="#D2D2D2"></stop>
<stop offset="100%" stop-opacity="0" stop-color="#D2D2D2"></stop>
</linearGradient>
<linearGradient id="linearGradient-9" y2="53.7335012%" x2="73.0360423%" y1="48.1527472%" x1="67.5652976%">
<stop offset="0%" stop-opacity="0" stop-color="#858585"></stop>
<stop offset="100%" stop-opacity="0.5" stop-color="#616161"></stop>
</linearGradient>
</defs>
<g fill-rule="evenodd" fill="none" stroke-width="1" stroke="none" id="空空如也">
<g>
<mask fill="white" id="mask-2">
<use xlink:href="#path-1"></use>
</mask>
<g id="蒙版"></g>
<g mask="url(#mask-2)" id="编组-3">
<g transform="translate(0, 238.0108)">
<g fill-rule="evenodd" fill="none" stroke-width="1" stroke="none" id="编组-2">
<g fill-rule="nonzero" transform="translate(162.5599, 0)" id="编组">
<polygon points="592.450826 498.100707 589.3555 489.432325 587.255101 479.632083 586.398359 469.500567 586.868185 459.341443 588.001295 452.660716 589.852963 446.421689 592.367915 440.541544 595.711972 435.158313 599.857498 430.520452 604.887401 426.517537 606.932527 428.091097 608.342006 430.133964 609.060564 432.508107 609.032927 435.075494 608.286732 439.989418 607.070711 451.308006 606.794343 463.813666 607.540538 469.25211 608.894743 472.371623 610.248947 473.448269 612.072979 473.393057 614.864299 471.791891 619.037461 467.319668 626.084854 456.884481 638.963619 434.136879 651.870021 411.527309 659.000324 400.705634 667.015006 390.325661 673.758394 383.451689 680.225413 378.42734 686.471338 374.921338 693.159452 372.491982 699.156645 371.470549 704.628738 371.691399 709.8521 373.126928 714.191083 375.639102 717.783871 379.283135 720.32646 383.755358 721.818849 389.331833 722.150491 396.343837 721.155565 403.328234 718.530066 411.610128 713.942351 421.493188 708.884811 429.471413 700.400302 443.19175 696.006046 451.031943 692.219799 458.706498 689.345568 466.270628 688.212458 472.09556 688.46119 475.601562 689.760121 477.920492 692.109252 479.438839 695.232214 479.714902 698.299903 479.052351 705.070927 476.540176 711.924862 473.393057 718.778797 470.383969 725.384001 468.258282 729.805894 467.595731 734.144877 467.8994 738.511497 469.224503 741.966102 471.377796 744.702148 474.055608 746.857821 477.368366 748.985858 483.000054 750.008421 489.349506 750.036057 495.864596 749.206952 502.158835 746.526179 511.40695 742.104286 520.737884 736.21764 529.406266 728.866242 537.16364 723.283601 541.608256 717.203498 545.25229 710.570657 548.123346 703.606175 550.000575 696.199503 550.745946 688.212458 550.359458 675.554788 548.09574 661.238907 544.396494 646.923027 539.537783 632.855878 533.464394 623.76336 528.688502 615.582857 523.526121 608.203822 517.977252 601.543344 511.683013 596.375256 505.140317 592.533736 498.349164" fill="url(#linearGradient-3)" id="路径"></polygon>
<polygon points="39.9075893 440.458725 31.7823599 436.096928 23.6571305 430.216783 16.222822 423.287598 9.75580266 415.447406 4.58771456 406.834236 1.21602073 397.834578 0.0552736694 391.623157 0 385.411737 1.05019972 379.117498 3.59278851 378.896647 5.99719313 379.421167 8.12522941 380.635845 9.83871316 382.54068 12.6023966 386.654021 19.2905106 395.87453 27.4986505 405.315889 31.6994494 408.849497 34.7947749 410.257419 36.5082587 410.146993 37.8348267 408.877103 38.8297528 405.840409 38.9403001 399.711807 37.1439059 387.26136 31.3401706 361.863552 25.7022563 336.465744 23.7676779 323.601202 22.8003886 310.488203 23.242578 300.881206 24.6520566 292.820163 26.9459139 286.056616 30.2899709 279.789983 34.0762172 275.014091 38.2770161 271.508089 43.1134622 269.078734 48.0051819 268.0573 53.1179963 268.333363 57.9820792 269.934529 62.8185253 273.081649 67.7655187 278.050785 71.6899493 283.903324 75.3103746 291.798729 78.5162474 302.206309 80.1191838 311.509637 83.0210515 327.383267 85.0385404 336.134468 87.4153082 344.361149 90.3448127 351.870067 93.4125013 356.977234 95.9550901 359.378984 98.4700421 360.234779 101.206089 359.765472 103.748678 357.888243 105.572709 355.320856 108.916766 348.916191 111.873907 342.014613 114.913959 335.195853 118.368563 329.23289 121.215157 325.782101 124.642125 323.159501 128.78765 321.254665 132.794991 320.509295 136.636511 320.674933 140.450394 321.696366 145.81194 324.429391 150.841844 328.459913 155.236101 333.291018 158.856526 338.508611 162.670409 345.575827 166.788298 354.823942 170.381086 364.762215 173.448775 375.639102 175.604448 386.764446 176.5441 397.641334 176.378279 404.874188 175.41099 411.499703 173.697506 417.628304 171.04437 423.315205 167.396308 428.173916 162.642772 432.314863 157.032495 435.62762 150.344381 438.691921 142.440246 441.424946 130.058944 444.572066 116.2958 446.835784 102.173378 448.160887 87.9956817 448.547375 74.0390802 447.967642 61.3537731 446.504508 49.4422973 443.964727 40.1563208 440.707182" fill="url(#linearGradient-4)" id="路径"></polygon>
<path fill="url(#linearGradient-5)" id="形状结合" d="M648.498327,284.510663 L644.71208,289.9215 L641.589118,295.939676 L639.350534,302.344341 L638.217424,308.914643 L638.355608,315.567765 L640.013818,322.165674 L642.224765,326.058164 L644.988449,328.92922 L647.94559,331.579426 L650.571089,334.505696 L652.284573,338.039304 L652.83731,343.588173 L651.648926,349.689168 L649.576163,355.458887 L646.619022,360.952544 L640.428371,370.780391 L633.408615,379.945687 L625.615028,388.53125 L630.092195,393.086292 L633.159883,398.524736 L634.735183,404.570518 L634.707546,410.947577 L633.215157,417.186603 L630.755479,423.066748 L627.411422,428.477585 L623.210623,433.336296 L620.115297,436.234959 L616.384325,438.25022 L612.459894,438.691921 L608.535464,438.25022 L595.960704,435.517195 L583.77286,431.624705 L571.944295,426.57275 L566.555112,423.729299 L561.608118,420.250904 L557.545504,416.027138 L554.864731,410.91997 L554.035626,406.723811 L554.25672,403.769935 L555.251646,401.699462 L557.877146,399.049256 L561.41466,396.895963 L562.685955,396.178199 L566.30638,393.886875 L569.180611,390.822574 L571.253374,386.129501 L572.524668,380.939514 L574.127604,370.55954 L575.150167,359.075314 L575.896362,352.781075 L577.444025,347.121781 L579.212782,343.864236 L581.783008,341.352061 L585.292886,339.557651 L592.533736,338.260154 L599.74695,336.935051 L604.389938,334.754152 L608.176185,331.827883 L611.243873,328.128637 L614.974846,321.696366 L618.180719,314.518725 L618.899277,312.751921 L622.630249,304.663271 L624.785922,300.908813 L627.273238,297.568449 L630.313289,294.86303 L634.292994,292.461281 L642.611681,288.430759 L646.28738,286.387892 L648.498327,284.510663 Z M619.009824,341.73855 L615.195941,346.514442 L606.158696,359.323771 L600.935334,367.854122 L595.463241,378.013245 L590.626795,388.807313 L586.702364,400.346752 L584.795423,408.269764 L583.717586,416.192776 L583.468855,424.115788 L595.325057,424.115788 L596.319983,416.109957 L599.912771,395.929742 L602.925186,383.313657 L607.153622,369.234437 L612.432257,355.238037 L619.009824,341.73855 Z"></path>
<polygon points="125.250135 60.7614951 125.250135 60.7614951 124.559214 54.3016178 122.431178 48.4214731 119.059484 43.2314863 114.55468 38.9249014 109.165497 35.7777818 102.947209 33.9005525 96.4525532 33.5416704 90.3171759 34.7011355 84.6239879 37.21331 79.6769945 40.9677686 75.6972903 45.7712671 72.8783332 51.6238055 66.8258663 52.3415696 61.3537731 54.5500746 56.6278743 58.111289 52.9245385 62.9147875 50.658318 68.5464754 49.9673972 74.3990138 50.8517759 80.2515521 53.3114542 85.8004211 57.1529742 90.4934943 61.9894203 93.8890708 67.5444241 95.931938 73.569254 96.4564579 123.011551 96.4564579 127.626903 95.7663001 131.634244 94.1375276 135.171759 91.5149279 137.963079 88.1193514 139.78711 84.1992549 140.699126 79.6442132 140.367484 74.9787463 139.013279 70.8654057 136.664148 67.1661597 133.513549 64.1294653 129.727302 62.0037792" fill="url(#linearGradient-6)" id="路径"></polygon>
<polygon points="329.569254 33.7073083 329.569254 33.5416704 329.127065 28.130833 327.911044 23.0788777 325.921192 18.3305919 321.665119 11.9259273 316.054842 6.65312145 311.715859 3.89249014 306.934686 1.84962298 301.656051 0.496913635 296.266868 0 291.071143 0.35888207 286.041239 1.49074091 278.993846 4.61025428 272.858469 9.22050857 269.376228 13.0301798 266.557271 17.3643709 264.318687 22.305901 259.205873 22.8856335 254.507611 24.2383429 250.196265 26.364029 246.299471 29.2074792 243.010688 32.6030557 240.302278 36.5783648 238.284789 40.9677686 237.096405 45.6608418 236.681853 50.7956161 237.234589 55.902784 238.588794 60.5682509 240.744467 64.8748357 243.591061 68.7673259 246.990392 72.0524771 250.970096 74.7855021 255.364353 76.7731567 260.062615 77.9878345 265.203066 78.3743228 326.612113 78.3743228 332.360574 77.6565587 337.501026 75.5860852 341.950556 72.3285403 345.488071 68.1047744 347.892475 63.1080317 348.997949 57.4211312 348.611033 51.6514118 346.842276 46.378606 343.885134 41.7683517 339.877793 37.9862868 335.041347 35.2808681 329.403433 33.8453398" fill="url(#linearGradient-7)" id="路径"></polygon>
</g>
<polygon points="1024 550.359458 999.596675 534.403009 973.839145 519.164324 946.672136 504.615797 918.040376 490.785034 889.159883 478.224161 859.118644 466.491478 827.833747 455.669804 795.305193 445.703925 762.693728 437.007936 729.114974 429.360987 694.541293 422.735472 658.917413 417.186603 623.348807 412.880018 587.034006 409.760505 549.945374 407.883276 512 407.193118 474.054626 407.883276 436.965994 409.760505 400.623556 412.880018 365.082587 417.186603 329.458707 422.735472 294.885026 429.360987 261.306272 437.007936 228.694807 445.703925 196.166253 455.669804 164.881356 466.491478 134.840117 478.224161 105.931987 490.785034 77.3278635 504.615797 50.160855 519.164324 24.4033251 534.403009 0 550.359458" fill-rule="nonzero" fill="url(#linearGradient-8)" id="路径"></polygon>
</g>
<polygon points="168.585366 389.215532 314.612039 389.215532 461.536985 469.266954 317.214646 465.594981" fill-rule="nonzero" fill="url(#linearGradient-9)" stroke="none" id="矩形"></polygon>
<polygon points="481.155803 208.25638 479.722777 299.451613 688.569371 236.303345" fill-rule="nonzero" fill="#B8D6FF" stroke="none" id="路径"></polygon>
<polygon points="314.788219 244.959395 481.155803 208.631248 481.155803 264.00952" fill-rule="nonzero" fill="#9CC6FF" stroke="none" id="路径"></polygon>
<polygon points="314.788219 244.959395 511.147006 264.384388 511.147006 512.547202 314.788219 465.075243" fill-rule="nonzero" fill="#64ADFF" stroke="none" id="路径"></polygon>
<polygon points="314.788219 244.959395 511.283486 263.617612 489.889892 383.428742 314.788219 346.994453" fill-rule="nonzero" fill="#429BFF" stroke="none" id="矩形"></polygon>
<polygon points="511.147006 264.384388 688.569371 236.303345 688.569371 458.600245 511.147006 512.547202" opacity="0.99" fill-rule="nonzero" fill="#9CC5FF" stroke="none" id="路径"></polygon>
<polygon points="511.283486 264.997809 671.897967 239.34913 688.569371 344.292902 535.025228 383.428742" fill-rule="nonzero" fill="#64ADFF" stroke="none" id="矩形"></polygon>
<polygon points="314.788219 244.959395 267.566573 324.806343 465.801946 362.565804 511.147006 264.384388" fill-rule="nonzero" fill="#9CC6FF" stroke="none" id="路径"></polygon>
<polygon points="511.147006 264.384388 566.898574 362.565804 745.583366 317.786082 688.569371 236.303345" opacity="0.99" fill-rule="nonzero" fill="#9DC6FF" stroke="none" id="路径"></polygon>
<path stroke-dasharray="11.05477807439905,8.29108355579929" fill="none" stroke-width="5.52738904" stroke="#9DC6FF" id="路径-19" d="M583.139543,151.843183 C532.695151,184.875351 501.824507,214.257045 511.001904,232.450753 C523.475834,257.179661 544.659409,246.913618 547.874,236.537816 C551.088588,226.162013 542.242035,205.908265 523.475834,216.933951 C504.709635,227.959637 484.261479,247.732311 479.722777,267.098145"></path>
<g transform="translate(555.0939, 41.4059)" fill-rule="evenodd" fill="none" stroke-width="1" stroke="none" id="飞机">
<polygon points="163.057977 9.09494702e-13 0 30.854292 41.4554178 58.3378535" fill-rule="nonzero" fill="#9DC6FF" id="路径-16备份"></polygon>
<polygon points="163.057977 0 41.4554178 58.3378535 41.4554178 104.894966" fill-rule="nonzero" fill="#64ADFF" id="路径-16备份-2"></polygon>
<polygon points="163.057977 0 41.4554178 58.3378535 65.4910753 84.1769753" fill-rule="nonzero" fill="#429BFF" id="路径-16备份-2"></polygon>
<polygon points="163.057977 0 58.9237102 70.0692202 108.951745 102.134572" fill-rule="nonzero" fill="#9DC6FF" id="路径-16备份"></polygon>
<line stroke-linecap="round" stroke-width="2.76369452" stroke="#429BFF" id="路径-16" y2="32.1173295" x2="0.501635492" y1="58.3378535" x1="41.4554178"></line>
<line stroke-linecap="round" stroke-width="2.76369452" stroke="#429BFF" id="路径-17" y2="101.744072" x2="107.648858" y1="71.0666294" x1="59.7211085"></line>
<line stroke-linecap="round" stroke-width="2.76369452" stroke="#429BFF" id="路径-18" y2="103.502333" x2="41.4554178" y1="58.3378535" x1="41.4554178"></line>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1739523534229" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14507" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><path d="M491.52 0c-20.48 0-40.96 2.275556-59.164444 4.551111-47.786667 4.551111-93.297778 15.928889-136.533334 31.857778 0 0 354.986667 336.782222 364.088889 350.435555V27.306667c-11.377778-4.551111-20.48-6.826667-31.857778-9.102223-43.235556-11.377778-88.746667-18.204444-136.533333-18.204444z" fill="#F26848" p-id="14508"></path><path d="M853.333333 118.328889c-13.653333-15.928889-29.582222-29.582222-45.511111-40.96C769.137778 45.511111 728.177778 20.48 682.666667 0c0 0 2.275556 530.204444 0 546.133333l273.066666-279.893333c-4.551111-11.377778-11.377778-20.48-15.928889-31.857778-22.755556-40.96-52.337778-79.644444-86.471111-116.053333z" fill="#5D62D4" p-id="14509"></path><path d="M987.591111 295.822222S650.808889 650.808889 637.155556 659.911111h359.537777c4.551111-11.377778 6.826667-20.48 9.102223-31.857778 11.377778-43.235556 18.204444-88.746667 18.204444-136.533333 0-20.48-2.275556-40.96-4.551111-59.164444-4.551111-47.786667-15.928889-93.297778-31.857778-136.533334z" fill="#4694E9" p-id="14510"></path><path d="M757.76 955.733333c9.102222-4.551111 20.48-11.377778 29.582222-15.928889 38.684444-22.755556 75.093333-52.337778 106.951111-86.471111 13.653333-13.653333 27.306667-29.582222 38.684445-45.511111 27.306667-38.684444 52.337778-79.644444 70.542222-125.155555 0 0-484.693333 2.275556-500.622222 0l254.862222 273.066666z" fill="#69DEF0" p-id="14511"></path><path d="M364.088889 637.155556v359.537777c11.377778 4.551111 20.48 6.826667 31.857778 9.102223 43.235556 11.377778 88.746667 18.204444 136.533333 18.204444 20.48 0 40.96-2.275556 59.164444-4.551111 47.786667-4.551111 93.297778-15.928889 136.533334-34.133333C728.177778 987.591111 373.191111 650.808889 364.088889 637.155556z" fill="#83E45A" p-id="14512"></path><path d="M341.333333 455.111111L68.266667 735.004444c4.551111 11.377778 11.377778 20.48 15.928889 31.857778 22.755556 43.235556 52.337778 81.92 86.471111 116.053334 13.653333 15.928889 29.582222 29.582222 45.511111 40.96C254.862222 955.733333 298.097778 983.04 341.333333 1001.244444c0 0-2.275556-530.204444 0-546.133333z" fill="#BBDC64" p-id="14513"></path><path d="M18.204444 395.946667c-11.377778 43.235556-18.204444 88.746667-18.204444 136.533333 0 20.48 2.275556 40.96 4.551111 59.164444 4.551111 47.786667 15.928889 93.297778 34.133333 136.533334 0 0 336.782222-354.986667 350.435556-364.088889H27.306667c-4.551111 11.377778-6.826667 20.48-9.102223 31.857778z" fill="#F3DF68" p-id="14514"></path><path d="M248.035556 84.195556C207.075556 106.951111 170.666667 136.533333 136.533333 170.666667c-13.653333 13.653333-27.306667 29.582222-40.96 45.511111C65.991111 254.862222 40.96 295.822222 22.755556 341.333333c0 0 507.448889-2.275556 523.377777 0L277.617778 68.266667c-11.377778 4.551111-20.48 11.377778-29.582222 15.928889z" fill="#F7B964" p-id="14515"></path></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1739523547121" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15535" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><path d="M1024 512c0 282.794667-229.205333 512-512 512S0 794.794667 0 512 229.205333 0 512 0s512 229.205333 512 512" fill="#FA7268" p-id="15536"></path><path d="M685.226667 355.157333h-27.374934a20.616533 20.616533 0 0 0-20.6848 20.718934c0 11.434667 9.216 20.650667 20.6848 20.650666h27.374934c5.051733 0 9.216 4.130133 9.216 9.216v305.732267a9.216 9.216 0 0 1-9.216 9.216H339.114667a9.216 9.216 0 0 1-9.216-9.216v-305.732267c0-5.085867 4.096-9.216 9.216-9.216h27.989333c11.4688 0 20.6848-9.216 20.6848-20.6848a20.616533 20.616533 0 0 0-20.6848-20.650666h-27.989333a50.517333 50.517333 0 0 0-50.5856 50.551466v305.732267c0 27.989333 22.562133 50.5856 50.551466 50.5856h345.8048a50.517333 50.517333 0 0 0 50.5856-50.5856v-305.732267a50.005333 50.005333 0 0 0-50.244266-50.5856" fill="#FFFFFF" p-id="15537"></path><path d="M480.6656 500.565333a20.923733 20.923733 0 0 0-29.2864 0l-86.8352 86.8352a20.923733 20.923733 0 0 0 0 29.2864c4.130133 4.096 9.216 6.007467 14.6432 6.007467a20.241067 20.241067 0 0 0 14.6432-6.007467l72.192-72.226133 72.226133 72.226133a4.949333 4.949333 0 0 0 1.604267 1.262934 20.241067 20.241067 0 0 0 13.0048 4.437333 18.944 18.944 0 0 0 9.250133-2.218667 20.514133 20.514133 0 0 0 5.393067-3.822933l86.8352-86.869333a20.855467 20.855467 0 0 0 0-29.252267 20.923733 20.923733 0 0 0-29.218133 0l-72.260267 72.226133-72.192-71.8848zM497.834667 440.763733c4.130133 4.130133 9.216 6.0416 14.6432 6.0416a20.206933 20.206933 0 0 0 14.6432-6.0416l87.7568-87.825066a20.855467 20.855467 0 0 0 0-29.252267 20.8896 20.8896 0 0 0-29.218134 0L532.821333 376.490667V231.424a20.616533 20.616533 0 0 0-20.650666-20.650667 20.616533 20.616533 0 0 0-20.6848 20.650667v144.418133l-52.189867-51.882666a20.957867 20.957867 0 0 0-29.252267 0 20.923733 20.923733 0 0 0 0 29.320533l87.790934 87.483733z" fill="#FFFFFF" p-id="15538"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1739523560037" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="16553" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><path d="M512 0C229.376 0 0 229.376 0 512s229.376 512 512 512 512-229.376 512-512S794.624 0 512 0z" fill="#0087FF" p-id="16554"></path><path d="M575.8976 449.536l-11.264-11.264c-5.7344-5.7344-11.264-5.7344-16.9984-5.7344-14.1312 0-25.6 11.264-25.6 25.6 0 5.7344 2.8672 11.264 5.7344 16.9984 0 0 2.8672 2.8672 5.7344 2.8672 2.8672 2.8672 5.7344 2.8672 5.7344 5.7344l2.8672 2.8672c25.6 25.6 16.9984 70.8608-8.6016 96.256l-110.592 110.592c-25.6 25.6-67.9936 25.6-93.5936 0l-2.8672-2.8672c-25.6-25.6-25.6-67.9936 0-93.5936l48.128-48.128c5.7344-5.7344 11.264-11.264 11.264-22.7328 0-14.1312-11.264-28.2624-28.2624-28.2624-5.7344 2.8672-11.264 5.7344-14.1312 8.6016-2.8672 0-2.8672 2.8672-5.7344 5.7344L286.72 560.3328c-48.128 48.128-48.128 127.5904 0 175.7184l2.8672 2.8672c48.128 48.128 127.5904 48.128 175.7184 0l110.592-110.592c48.128-51.4048 50.9952-127.7952 0-178.7904z m0 0" fill="#FFFFFF" p-id="16555"></path><path d="M737.4848 287.9488c-50.9952-50.9952-130.4576-50.9952-175.7184-2.8672l-113.2544 110.592c-48.128 48.128-50.9952 116.1216-2.8672 167.1168h2.8672l11.264 11.264c2.8672 2.8672 8.6016 2.8672 11.264 2.8672 14.1312 0 22.7328-11.264 22.7328-22.7328 0-2.8672 0-8.6016-2.8672-11.264 0-5.7344-5.7344-11.264-8.6016-14.1312l-2.8672-2.8672c-25.6-25.6-16.9984-62.2592 8.6016-90.7264l110.592-110.592c25.6-25.6 67.9936-25.6 93.5936 0l2.8672 2.8672c25.6 25.6 25.6 67.9936 0 93.5936l-48.128 48.128c-5.7344 5.7344-11.264 14.1312-11.264 22.7328 0 14.1312 11.264 28.2624 28.2624 28.2624 5.7344 0 11.264 0 14.1312-2.8672 2.8672-2.8672 5.7344-2.8672 5.7344-5.7344l50.9952-48.128c50.7904-50.7904 50.7904-127.3856 2.6624-175.5136z m0 0" fill="#FFFFFF" p-id="16556"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1739523523170" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13404" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#15C521" p-id="13405"></path><path d="M665.00608 409.47712c3.66592 0 7.24992 0.06144 10.83392 0.24576-18.96448-104.448-125.68576-184.44288-254.50496-184.44288C279.10144 225.28 163.84 322.7648 163.84 443.02336c0 70.51264 39.6288 133.20192 101.0688 172.99456l2.37568 1.49504L242.3808 696.32l93.10208-47.9232 4.38272 1.24928a300.8512 300.8512 0 0 0 98.77504 10.56768 170.63936 170.63936 0 0 1-8.192-51.93728c0-109.7728 105.0624-198.79936 234.55744-198.79936z m-154.07104-76.84096c19.968 0 36.1472 15.83104 36.1472 35.40992 0 19.51744-16.1792 35.38944-36.1472 35.38944-19.98848 0-36.1472-15.872-36.1472-35.38944 0-19.57888 16.1792-35.40992 36.1472-35.40992z m-179.2 70.79936c-19.968 0-36.1472-15.872-36.1472-35.38944 0-19.57888 16.1792-35.40992 36.1472-35.40992 19.98848 0 36.18816 15.83104 36.18816 35.40992 0 19.51744-16.19968 35.38944-36.18816 35.38944z" fill="#FFFFFF" p-id="13406"></path><path d="M450.56 609.93536c0 99.34848 96.29696 179.89632 215.08096 179.89632a253.07136 253.07136 0 0 0 71.68-10.26048L815.02208 819.2l-20.76672-65.1264 1.98656-1.2288C847.52384 719.9744 880.64 668.20096 880.64 609.93536 880.64 510.60736 784.384 430.08 665.64096 430.08 546.85696 430.08 450.56 510.60736 450.56 609.93536z m259.6864-61.952c0-16.13824 13.49632-29.20448 30.16704-29.20448 16.71168 0 30.22848 13.06624 30.22848 29.20448 0 16.1792-13.5168 29.26592-30.22848 29.26592-16.67072 0-30.16704-13.1072-30.16704-29.26592z m-149.66784 0c0-16.13824 13.5168-29.20448 30.208-29.20448 16.6912 0 30.208 13.06624 30.208 29.20448 0 16.1792-13.5168 29.26592-30.208 29.26592-16.6912 0-30.208-13.1072-30.208-29.26592z" fill="#FFFFFF" p-id="13407"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 KiB

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