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

View File

@@ -0,0 +1,68 @@
---
description: bdrp-app 页面写法(参考 bdrp-mini
globs: src/pages/**/*.vue
alwaysApply: false
---
# bdrp-app 页面规范(按 mini 风格)
- 使用 Vue SFC + `script setup`,不要混用 Options API。
- 页面元信息统一写在 `definePage(...)`,并放在脚本顶部。
- `layout` 只用三种:`home`(首页/资产/我的/推广类)、`default`(普通业务页)、`false`(登录/分享/维护等无壳页面)。
- 站内路由跳转必须使用 uni 官方导航 API不使用 `window.location.href`、`router.push`、`router.back`。
- 跳转规范:普通页用 `uni.navigateTo`,替换当前页用 `uni.redirectTo`,回首页/重开流程用 `uni.reLaunch`tab 页切换用 `uni.switchTab`,返回用 `uni.navigateBack`。
- 接口调用使用 `async/await` + `try/catch`;失败统一 `uni.showToast({ title: 'xxx', icon: 'none' })`。
- 页面不要直接硬编码产品枚举,优先后端配置 + 本地白名单兜底(与 mini 的动态加载策略一致)。
- 页面模板优先使用 uni 标签:`<view>`、`<text>`、`<image>`、`<scroll-view>`、`<swiper>`;避免在页面层大量使用纯 H5 标签(如 `div/span/img`)。
- 图片与资源使用 uni 写法:`<image :src="..." mode="aspectFill|aspectFit" />`,避免直接依赖 H5 行为。
- 生命周期优先使用 uni 页面生命周期:`onLoad`、`onShow`、`onPullDownRefresh`、`onReachBottom`、`onUnload`;通用副作用再用 Vue 的 `onMounted/onUnmounted`。
- 页面文件尾部允许并推荐使用 `<route ...>` 声明页面配置(如 `layout`、`style`、`title`、`auth`、`agent`);与 `definePage(...)` 保持一致,避免冲突。
- UI 组件库统一优先使用 `wot-design-uni``wd-*` 组件);新增或重构页面时不再引入 `vant` 组件。
- 历史页面若存在 `van-*` 组件(如 `van-button`、`van-icon`、`van-popup`、`van-field`),重构时应替换为 `wd-*` 或 uni 原生标签实现。
## 示例
```vue
<script setup>
definePage({ layout: 'default' })
onLoad(() => {
// 页面参数初始化
})
onShow(() => {
// 页面每次可见时刷新
})
function toDetail() {
uni.navigateTo({ url: '/pages/help-detail' })
}
function back() {
uni.navigateBack()
}
</script>
```
```vue
<script setup>
// ❌ 不建议:站内页跳转使用 window/router
function toDetail() {
window.location.href = '/pages/help-detail'
}
function toList() {
router.push('/pages/help')
}
</script>
```
```vue
<route type="home" lang="json">
{
"layout": "home",
"style": {
"navigationStyle": "custom"
}
}
</route>
```

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

50
.env Normal file
View File

@@ -0,0 +1,50 @@
#############################
# API
#############################
# 与 `src/constants/env.ts` 拼成 apiBaseUrl供 useApiFetch / request / 热更新等
VITE_API_URL=https://chimei.ronsafe.cn
VITE_API_PREFIX=/api/v1
#############################
# 品牌展示
#############################
# `src/layouts/home.vue` 必需
VITE_COMPANY_NAME=戎行技术有限公司
# `src/components/Payment.vue` 等;未配时默认 "App"
VITE_APP_NAME=赤眉
#############################
# 查询表单加密(须与后端 Encrypt.SecretKey 等业务约定一致)
#############################
# `src/components/InquireForm.vue` 必需
VITE_INQUIRE_AES_KEY=ff83609b2b24fc73196aac3d3dfb874f
#############################
# 提现
#############################
# `src/pages/withdraw.vue` 必需。例both / alipay / bankcard
VITE_WITHDRAW_METHODS=both
#############################
# 站点 / 报告 WebView / 404
#############################
# `useReportWebview`、`QRcode` 基地址;可与下一项二选一或同时填
VITE_SITE_ORIGIN=https://chimei.ronsafe.cn
# 报告 H5 根地址(可选,优先与 VITE_SITE_ORIGIN 配合使用)
VITE_REPORT_BASE_URL=
# `src/pages/not-found.vue` 必需
VITE_SEO_SITE_NAME=赤眉
#############################
# 客服(企业微信)
#############################
VITE_CUSTOMER_SERVICE_URL=
VITE_WXWORK_CORP_ID=
# 设为 1 则跳过微信 SDK直接系统打开客服链接基座不支持客服能力时使用
# VITE_CUSTOMER_SERVICE_SKIP_SDK=1
#############################
# 邀请链接加密(须与后端代理链路 key 一致)
#############################
VITE_INVITE_CHANNEL_KEY=8e3e7a2f60edb49221e953b9c029ed10

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# unh
pages.json
manifest.json

9
.hbuilderx/launch.json Normal file
View File

@@ -0,0 +1,9 @@
{
"version" : "1.0",
"configurations" : [
{
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}

1
.node-version Normal file
View File

@@ -0,0 +1 @@
22

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist=true

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

@@ -0,0 +1,11 @@
{
"recommendations": [
"antfu.vite",
"antfu.iconify",
"antfu.unocss",
"vue.volar",
"dbaeumer.vscode-eslint",
"editorConfig.editorConfig",
"uni-helper.uni-helper-vscode"
]
}

14
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug h5",
"type": "chrome",
"runtimeArgs": ["--remote-debugging-port=9222"],
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}",
"preLaunchTask": "uni:h5"
}
]
}

62
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,62 @@
{
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
],
// Enable file nesting
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"vite.config.*": "pages.config.*, manifest.config.*, uno.config.*, volar.config.*, *.env, .env.*"
},
// Associate JSON files with comments as JSONC
"files.associations": {
"**/pages.json": "jsonc",
"**/manifest.json": "jsonc"
}
}

16
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "uni:h5",
"type": "npm",
"script": "dev --devtools",
"isBackground": true,
"problemMatcher": "$vite",
"group": {
"kind": "build",
"isDefault": true
}
}
]
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023-PRESENT KeJun
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

33
README.md Normal file
View File

@@ -0,0 +1,33 @@
<p align="center">
<img src="https://github.com/uni-helper/vitesse-uni-app/raw/main/.github/images/preview.png" width="300"/>
</p>
<h2 align="center">
Vitesse for uni-app
</h2>
<p align="center">
<a href="https://vitesse-uni-app.netlify.app/">📱 在线预览</a>
<a href="https://uni-helper.js.org/vitesse-uni-app">📖 阅读文档</a>
</p>
## 特性
- ⚡️ [Vue 3](https://github.com/vuejs/core), [Vite](https://github.com/vitejs/vite), [pnpm](https://pnpm.io/), [esbuild](https://github.com/evanw/esbuild) - 就是快!
- 🔧 [ESM 优先](https://github.com/uni-helper/plugin-uni)
- 🗂 [基于文件的路由](./src/pages)
- 📦 [组件自动化加载](./src/components)
- 📑 [布局系统](./src/layouts)
- 🎨 [UnoCSS](https://github.com/unocss/unocss) - 高性能且极具灵活性的即时原子化 CSS 引擎
- 😃 [各种图标集为你所用](https://github.com/antfu/unocss/tree/main/packages/preset-icons)
- 🔥 使用 [新的 `<script setup>` 语法](https://github.com/vuejs/rfcs/pull/227)
- 📥 [API 自动加载](https://github.com/antfu/unplugin-auto-import) - 直接使用 Composition API 无需引入
- 🦾 [TypeScript](https://www.typescriptlang.org/) & [ESLint](https://eslint.org/) - 保证代码质量

5
eslint.config.js Normal file
View File

@@ -0,0 +1,5 @@
import uni from '@uni-helper/eslint-config'
export default uni({
unocss: true,
})

25
index.html Normal file
View File

@@ -0,0 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<link rel="icon" href="static/logo.png" />
<script>
const coverSupport =
"CSS" in window &&
typeof CSS.supports === "function" &&
(CSS.supports("top: env(a)") || CSS.supports("top: constant(a)"));
document.write(
`<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0${
coverSupport ? ", viewport-fit=cover" : ""
}" />`,
);
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

56
manifest.config.ts Normal file
View File

@@ -0,0 +1,56 @@
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
export default defineManifestConfig({
'name': 'bdrp-app',
'appid': '',
'description': '',
'versionName': '1.0.0',
'versionCode': '100',
'transformPx': false,
/* 5+App特有相关 */
'app-plus': {
usingComponents: true,
nvueStyleCompiler: 'uni-app',
compilerVersion: 3,
splashscreen: {
alwaysShowBeforeRender: true,
waiting: true,
autoclose: true,
delay: 0,
},
/* 模块配置 */
modules: {},
/* 应用发布信息 */
distribute: {
/* android打包配置 */
android: {
permissions: [
'<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',
'<uses-permission android:name="android.permission.VIBRATE"/>',
'<uses-permission android:name="android.permission.READ_LOGS"/>',
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>',
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.CAMERA"/>',
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
'<uses-permission android:name="android.permission.READ_PHONE_STATE"/>',
'<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>',
'<uses-permission android:name="android.permission.WAKE_LOCK"/>',
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
'<uses-feature android:name="android.hardware.camera"/>',
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
],
},
/* ios打包配置 */
ios: {},
/* SDK配置 */
sdkConfigs: {},
},
},
// 迁移目标仅 App 端,微信 H5/小程序配置不启用
'uniStatistics': {
enable: false,
},
'vueVersion': '3',
})

101
package.json Normal file
View File

@@ -0,0 +1,101 @@
{
"name": "bdrp-app",
"type": "module",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@9.15.4",
"license": "MIT",
"scripts": {
"dev": "unh dev",
"build": "unh build",
"build:app": "unh build -p app",
"build:app-android": "unh build -p app-android",
"build:app-ios": "unh build -p app-ios",
"about": "unh info",
"type-check": "vue-tsc --noEmit",
"test": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"postinstall": "npx simple-git-hooks"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4080720251210001",
"@dcloudio/uni-app-harmony": "3.0.0-4080720251210001",
"@dcloudio/uni-app-plus": "3.0.0-4080720251210001",
"@dcloudio/uni-components": "3.0.0-4080720251210001",
"@dcloudio/uni-h5": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-alipay": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-baidu": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-harmony": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-jd": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-lark": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-qq": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-weixin": "3.0.0-4080720251210001",
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
"@vant/area-data": "^2.1.0",
"@vueuse/core": "^13.5.0",
"crypto-js": "^4.2.0",
"echarts": "^6.0.0",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"nprogress": "^0.2.0",
"pinia": "^2.3.1",
"qrcode": "^1.5.4",
"uqrcodejs": "^4.0.7",
"vue": "3.4.21",
"vue-echarts": "^8.0.1",
"vue-i18n": "^9.14.5",
"vue-router": "4.5.1",
"wot-design-uni": "^1.14.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.16",
"@dcloudio/uni-automator": "3.0.0-4080720251210001",
"@dcloudio/uni-cli-shared": "3.0.0-4080720251210001",
"@dcloudio/uni-stacktracey": "3.0.0-4080720251210001",
"@dcloudio/vite-plugin-uni": "3.0.0-4080720251210001",
"@iconify-json/carbon": "^1.2.11",
"@mini-types/alipay": "^3.0.14",
"@types/node": "^24.1.0",
"@uni-helper/eslint-config": "^0.4.0",
"@uni-helper/plugin-uni": "^0.1.0",
"@uni-helper/unh": "^0.2.3",
"@uni-helper/uni-env": "^0.1.7",
"@uni-helper/uni-types": "1.0.0-alpha.6",
"@uni-helper/unocss-preset-uni": "^0.2.11",
"@uni-helper/vite-plugin-uni-components": "^0.2.3",
"@uni-helper/vite-plugin-uni-layouts": "^0.1.11",
"@uni-helper/vite-plugin-uni-manifest": "^0.2.9",
"@uni-helper/vite-plugin-uni-pages": "^0.3.19",
"@unocss/eslint-config": "^66.3.3",
"@vue/runtime-core": "3.4.21",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.32.0",
"lint-staged": "^16.1.2",
"miniprogram-api-typings": "^4.1.0",
"sass": "^1.78.0",
"simple-git-hooks": "^2.13.0",
"typescript": "~5.8.3",
"unocss": "66.0.0",
"unplugin-auto-import": "^19.3.0",
"vite": "^5.2.8",
"vite-plugin-uni-polyfill": "^0.1.0",
"vitest": "^4.0.18",
"vitest-environment-uniapp": "^0.0.5",
"vue-tsc": "^3.0.4"
},
"pnpm": {
"overrides": {
"unconfig": "7.3.2"
}
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
}
}

49
pages.config.ts Normal file
View File

@@ -0,0 +1,49 @@
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
export default defineUniPages({
pages: [
{ path: 'pages/agent', style: { navigationBarTitleText: '代理中心' } },
{ path: 'pages/agent-manage-agreement', style: { navigationBarTitleText: '代理管理协议', navigationStyle: 'default' } },
{ path: 'pages/agent-promote-details', auth: true, style: { navigationBarTitleText: '直推收益明细' } },
{ path: 'pages/agent-rewards-details', auth: true, style: { navigationBarTitleText: '代理奖励明细' } },
{ path: 'pages/agent-service-agreement', style: { navigationBarTitleText: '信息技术服务合同', navigationStyle: 'default' } },
{ path: 'pages/agent-vip', auth: true, style: { navigationBarTitleText: '代理会员' } },
{ path: 'pages/agent-vip-apply', auth: true, style: { navigationBarTitleText: 'VIP申请' } },
{ path: 'pages/agent-vip-config', auth: true, style: { navigationBarTitleText: 'VIP配置' } },
{ path: 'pages/authorization', style: { navigationBarTitleText: '授权书', navigationStyle: 'default' } },
{ path: 'pages/help', style: { navigationBarTitleText: '帮助中心' } },
{ path: 'pages/help-detail', style: { navigationBarTitleText: '帮助详情' } },
{ path: 'pages/help-guide', style: { navigationBarTitleText: '引导指南' } },
{ path: 'pages/history-query', auth: true, style: { navigationBarTitleText: '历史报告' } },
{ path: 'pages/index', style: { navigationBarTitleText: '首页' } },
{ path: 'pages/inquire', style: { navigationBarTitleText: '查询报告' } },
{ path: 'pages/invitation', auth: true, style: { navigationBarTitleText: '邀请下级' } },
{ path: 'pages/invitation-agent-apply', auth: true, style: { navigationBarTitleText: '代理申请' } },
{ path: 'pages/login', style: { navigationBarTitleText: '登录' } },
{ path: 'pages/me', style: { navigationBarTitleText: '我的' } },
{ path: 'pages/cancel-account', auth: true, style: { navigationBarTitleText: '注销账号', navigationStyle: 'default' } },
{ path: 'pages/not-found', style: { navigationBarTitleText: '页面不存在' } },
{ path: 'pages/payment-result', auth: true, style: { navigationBarTitleText: '支付结果' } },
{ path: 'pages/privacy-policy', style: { navigationBarTitleText: '隐私政策', navigationStyle: 'default' } },
{ path: 'pages/promote', auth: true, style: { navigationBarTitleText: '推广管理' } },
{ path: 'pages/promotion-inquire', style: { navigationBarTitleText: '推广查询' } },
{ path: 'pages/report-example-webview', style: { navigationBarTitleText: '示例报告', navigationStyle: 'default' } },
{ path: 'pages/report-result-webview', auth: true, style: { navigationBarTitleText: '报告结果', navigationStyle: 'default' } },
{ path: 'pages/report-share', style: { navigationBarTitleText: '报告分享', navigationStyle: 'default' } },
{ path: 'pages/subordinate-detail', auth: true, style: { navigationBarTitleText: '下级详情' } },
{ path: 'pages/subordinate-list', auth: true, style: { navigationBarTitleText: '我的下级' } },
{ path: 'pages/user-agreement', style: { navigationBarTitleText: '用户协议', navigationStyle: 'default' } },
{ path: 'pages/withdraw', auth: true, style: { navigationBarTitleText: '提现' } },
{ path: 'pages/withdraw-details', auth: true, style: { navigationBarTitleText: '提现记录' } },
],
globalStyle: {
backgroundColor: '@bgColor',
backgroundColorBottom: '@bgColorBottom',
backgroundColorTop: '@bgColorTop',
backgroundTextStyle: '@bgTxtStyle',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
navigationBarTitleText: 'BDRP',
navigationStyle: 'custom',
},
})

13811
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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>

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