chore: 初始开源发布

iotlcm-app:小程序端 + 用户端服务。

- app/:uni-app 小程序前端(基于 unibest,MIT)
  - 微信 AppID → YOUR_WX_APPID,uni-app AppID → __UNI__YOURAPPID
  - 内网后端地址 → admin.iotlcm.example.com
  - 应用标题与 unibest 模板 README 重写(保留上游致谢与 MIT 声明)
- server/:GoFrame 用户端服务(基于 XiuAdmin,Apache-2.0)
  - module xiuadmin → git.5iot.com/iotlcm-public/iotlcm-app(360 文件)
  - 支付回调地址 → app.iotlcm.example.com,商户名与应用名泛化
  - 真实手机号 → 合法 11 位编造号(保留号段前 3 位 + 后 4 位)
  - 依赖号码尾部的断言同步更新
  - 补 server/LICENSE(Apache-2.0)
- 移除内部资产:CI 触发器
- 补根 README,LF 行尾 + .gitattributes

已知:TestSysDept_RefreshDeptAncestors 在源仓同一提交上同样失败,属既有缺陷。
This commit is contained in:
MiFaZhan
2026-10-26 14:01:25 +08:00
commit 2ed5dfa6a1
1013 changed files with 138466 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
}
+11
View File
@@ -0,0 +1,11 @@
.git
.idea
.hbuilderx
node_modules/
dist/
unpackage/
.vite/
.stylelintcache
.eslintcache
*.log
*.local
+13
View File
@@ -0,0 +1,13 @@
root = true
[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符集为 utf-8
indent_style = space # 缩进风格(tab | space)
indent_size = 2 # 缩进大小
end_of_line = lf # 控制换行类型(lf | cr | crlf)
trim_trailing_whitespace = true # 去除行首的任意空白字符
insert_final_newline = true # 始终在文件末尾插入一个新行
[*.md] # 表示仅 md 文件适用以下规则
max_line_length = off # 关闭最大行长度限制
trim_trailing_whitespace = false # 关闭末尾空格修剪
+6
View File
@@ -0,0 +1,6 @@
node_modules
private.*.key
private.key
# Local Claude API credentials
scripts/claude-instances.local.ps1
+9
View File
@@ -0,0 +1,9 @@
# registry = https://registry.npmjs.org
registry = https://registry.npmmirror.com
strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist=true
ignore-workspace-root-check=true
install-workspace-root=true
node-options=--max-old-space-size=8192
+9
View File
@@ -0,0 +1,9 @@
dist
node_modules
src/pages.json
src/manifest.json
src/service
auto-import.d.ts
uni-pages.d.ts
src/uni_modules
scripts
+6
View File
@@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+129
View File
@@ -0,0 +1,129 @@
# Build context: app/
# Example:
# docker build -f Dockerfile -t iotlcm-app-web:latest .
FROM crpi-svwhn8x8nvask31h.cn-beijing.personal.cr.aliyuncs.com/xjzlpublic/node:24-alpine AS builder
WORKDIR /build/app
COPY package.json pnpm-lock.yaml .npmrc ./
RUN npm config set registry https://registry.npmmirror.com/ && \
npm install -g pnpm@10.10.0 --registry=https://registry.npmmirror.com/ && \
pnpm config set registry https://registry.npmmirror.com/ && \
pnpm install --frozen-lockfile --ignore-scripts
COPY index.html vite.config.ts manifest.config.ts pages.config.ts tsconfig.json ./
COPY uno.config.ts wot-ui-resolver.ts openapi-ts-request.config.ts ./
COPY folder-alias.json favicon.ico ./
COPY env ./env
COPY public ./public
COPY scripts ./scripts
COPY src ./src
COPY vite-plugins ./vite-plugins
ARG VITE_SERVER_BASEURL=/app/api/v1
ARG VITE_MP_SERVER_BASEURL=http://yznw2.xiujie.cn:8010/api/v1
ARG VITE_APP_PUBLIC_BASE=/app/
RUN printf '%s\n' \
"VITE_SERVER_BASEURL='${VITE_SERVER_BASEURL}'" \
"VITE_MP_SERVER_BASEURL='${VITE_MP_SERVER_BASEURL}'" \
"VITE_APP_PUBLIC_BASE='${VITE_APP_PUBLIC_BASE}'" \
> env/.env.production.local
RUN pnpm init-baseFiles
RUN CI=true pnpm build:h5:prod
FROM crpi-svwhn8x8nvask31h.cn-beijing.personal.cr.aliyuncs.com/xjzlpublic/nginx:1.30.0-alpine AS runtime
COPY --from=builder /build/app/dist/build/h5 /usr/share/nginx/html
RUN printf '%s\n' \
'server {' \
' listen 80;' \
' server_name _;' \
' root /usr/share/nginx/html;' \
' index index.html;' \
' resolver 127.0.0.11 ipv6=off valid=30s;' \
' set $app_backend http://iotlcm-app-server:8010;' \
'' \
' client_max_body_size 200m;' \
'' \
' location = /app {' \
' return 301 /app/;' \
' }' \
'' \
' location ^~ /app/api/ {' \
' rewrite ^/app/(.*)$ /$1 break;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_pass $app_backend;' \
' }' \
'' \
' location ^~ /app/upload/ {' \
' rewrite ^/app/(.*)$ /$1 break;' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_pass $app_backend;' \
' }' \
'' \
' location ^~ /app/assets/ {' \
' rewrite ^/app/(.*)$ /$1 break;' \
' expires 30d;' \
' add_header Cache-Control "public, immutable";' \
' try_files $uri =404;' \
' }' \
'' \
' location ^~ /app/ {' \
' rewrite ^/app/(.*)$ /$1 break;' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' gzip on;' \
' gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;' \
'' \
' location / {' \
' try_files $uri $uri/ /index.html;' \
' }' \
'' \
' location = /favicon.ico {' \
' alias /usr/share/nginx/html/static/images/shandong-yunze-logo.png;' \
' add_header Cache-Control "no-cache";' \
' }' \
'' \
' location /api/ {' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_pass $app_backend;' \
' }' \
'' \
' location /upload/ {' \
' proxy_http_version 1.1;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
' proxy_set_header X-Forwarded-Proto $scheme;' \
' proxy_pass $app_backend;' \
' }' \
'' \
' location /assets/ {' \
' expires 30d;' \
' add_header Cache-Control "public, immutable";' \
' try_files $uri =404;' \
' }' \
'}' \
> /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 菲鸽
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.
+66
View File
@@ -0,0 +1,66 @@
<p align="center">
<img width="120" src="./src/static/logo.svg">
</p>
<h1 align="center">iotlcm-app-ui</h1>
<div align="center">
iotLCM 物联网卡全生命周期管理平台的**小程序端**。
基于 [unibest](https://github.com/unibest-tech/unibest)(MIT License)二次开发。
[![node version](https://img.shields.io/badge/node-%3E%3D18-green)](https://nodejs.org)
</div>
## 技术栈
`uniapp` + `Vue3` + `TypeScript` + `Vite5` + `UnoCSS` + `wot-ui` + `z-paging`
## 本地开发
```bash
pnpm install
pnpm dev:mp-weixin # 微信小程序
pnpm dev:h5 # H5
```
## 构建
```bash
pnpm build:mp-weixin # 微信小程序(产物在 dist/build/mp-weixin)
pnpm build:h5
```
## 配置
环境变量文件在 `env/`:
| 文件 | 用途 |
| --- | --- |
| `.env` | 通用配置(应用标题、端口、uni-app AppID、微信 AppID、公开路径) |
| `.env.development` | 开发环境(后端地址) |
| `.env.production` | 生产环境 |
| `.env.test` | 测试环境 |
**公开版里的标识是占位符**:
| 变量 | 占位 | 说明 |
| --- | --- | --- |
| `VITE_WX_APPID` | `YOUR_WX_APPID` | 微信小程序 AppID |
| `VITE_UNI_APPID` | `__UNI__YOURAPPID` | uni-app 应用标识 |
| `VITE_SERVER_BASEURL` | `http://localhost:8010/api/v1` | 后端地址 |
| `VITE_MP_SERVER_BASEURL` | `https://admin.iotlcm.example.com/app/api/v1` | 生产后端地址 |
## 上游与许可
本项目基于 **[unibest](https://github.com/unibest-tech/unibest)**(MIT License)二次开发。
- 上游版权声明保留在 [`LICENSE`](./LICENSE)
- iotLCM 相关的业务页面、路由、配置为本项目新增
- 上游仓库:[unibest-tech/unibest](https://github.com/unibest-tech/unibest)
## License
[MIT](./LICENSE)
+31
View File
@@ -0,0 +1,31 @@
VITE_APP_TITLE = '山东平台物联卡'
VITE_APP_PORT = 9000
VITE_UNI_APPID = '__UNI__YOURAPPID'
VITE_WX_APPID = 'YOUR_WX_APPID'
# 微信开发者工具 CLI 路径,仅当默认安装路径不正确时配置(就是当 pnpm dev:mp 无法自动打开微信开发者工具时,才需要配置,通常是你更改了默认的安装位置导致的,一般出现在windows系统)
# macOS 示例:
# WECHAT_DEVTOOLS_CLI_PATH = '/Applications/wechatwebdevtools.app/Contents/MacOS/cli'
# Windows 示例:
# WECHAT_DEVTOOLS_CLI_PATH = 'C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat'
# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base
# https://uniapp.dcloud.net.cn/collocation/manifest.html#h5-router
# 比如你要部署到 https://unibest.tech/doc/ ,则配置为 /doc/
VITE_APP_PUBLIC_BASE=/
# 默认后台请求地址
# 不同命令会按 mode 叠加读取 .env.development / .env.test / .env.production。
VITE_SERVER_BASEURL = 'http://localhost:8010/api/v1'
# 备注:如果后台带统一前缀,则也要加到后面,eg: https://ukw0y1.laf.run/api
# 微信小程序专用后台请求地址,构建后会直接请求这个地址。
# 不配置时会回退使用 VITE_SERVER_BASEURL。
VITE_MP_SERVER_BASEURL = 'https://admin.iotlcm.example.com/app/api/v1'
# 认证模式,'single' | 'double' ==> 单token | 双token
VITE_AUTH_MODE = 'single'
# 原生插件资源复制开关,启用后 App 构建会把根目录 nativeplugins 复制到 dist
VITE_COPY_NATIVE_RES_ENABLE = true
+10
View File
@@ -0,0 +1,10 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false
# development mode 后台请求地址
VITE_SERVER_BASEURL = 'http://localhost:8010/api/v1'
VITE_MP_SERVER_BASEURL = 'https://admin.iotlcm.example.com/app/api/v1'
+11
View File
@@ -0,0 +1,11 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'production'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = true
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false
# production mode 后台请求地址
VITE_APP_PUBLIC_BASE = '/app/'
VITE_SERVER_BASEURL = '/app/api/v1'
VITE_MP_SERVER_BASEURL = 'https://admin.iotlcm.example.com/app/api/v1'
+10
View File
@@ -0,0 +1,10 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false
# test mode 后台请求地址
VITE_SERVER_BASEURL = 'http://localhost:8010/api/v1'
VITE_MP_SERVER_BASEURL = 'https://admin.iotlcm.example.com/app/api/v1'
+60
View File
@@ -0,0 +1,60 @@
import uniHelper from '@uni-helper/eslint-config'
export default uniHelper({
unocss: true,
vue: true,
markdown: false,
ignores: [
// 忽略uni_modules目录
'**/uni_modules/',
// 忽略原生插件目录
'**/nativeplugins/',
'dist',
// unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用
'auto-import.d.ts',
// vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore
'uni-pages.d.ts',
// 插件生成的文件
'src/pages.json',
'src/manifest.json',
// 忽略自动生成文件
'src/service/**',
],
// https://eslint-config.antfu.me/rules
rules: {
'no-useless-return': 'off',
'no-console': 'off',
'no-unused-vars': 'off',
'vue/no-unused-refs': 'off',
'unused-imports/no-unused-vars': 'off',
'eslint-comments/no-unlimited-disable': 'off',
'jsdoc/check-param-names': 'off',
'jsdoc/require-returns-description': 'off',
'ts/no-empty-object-type': 'off',
'no-extend-native': 'off',
// uni 条件编译注释可能包裹 import,自动排序会破坏平台条件边界
'perfectionist/sort-imports': 'off',
'vue/singleline-html-element-content-newline': [
'error',
{
externalIgnores: ['text'],
},
],
// vue SFC 调换顺序改这里
'vue/block-order': ['error', {
order: [['script', 'template'], 'style'],
}],
},
formatters: {
/**
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* By default uses Prettier
*/
css: true,
/**
* Format HTML files
* By default uses Prettier
*/
html: true,
},
})
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+5
View File
@@ -0,0 +1,5 @@
{
"src/pages/home/components/CardOverview.vue": {
"description": "卡片概览"
}
}
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html build-time="%BUILD_TIME%">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/static/images/shandong-yunze-logo.png?v=20260721-2" type="image/png" />
<link rel="shortcut icon" href="/static/images/shandong-yunze-logo.png?v=20260721-2" type="image/png" />
<link rel="apple-touch-icon" href="/static/images/shandong-yunze-logo.png?v=20260721-2" />
<script>
var 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>%VITE_APP_TITLE%</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
import path from 'node:path'
import process from 'node:process'
// manifest.config.ts
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
import { loadEnv } from 'vite'
// 手动解析命令行参数获取 mode
function getMode() {
const args = process.argv.slice(2)
const modeFlagIndex = args.findIndex(arg => arg === '--mode')
return modeFlagIndex !== -1 ? args[modeFlagIndex + 1] : args[0] === 'build' ? 'production' : 'development' // 默认 development
}
// 获取环境变量的范例
const env = loadEnv(getMode(), path.resolve(process.cwd(), 'env'))
const {
VITE_APP_TITLE,
VITE_UNI_APPID,
VITE_WX_APPID,
VITE_APP_PUBLIC_BASE,
VITE_FALLBACK_LOCALE,
} = env
// console.log('manifest.config.ts env:', env)
export default defineManifestConfig({
'name': VITE_APP_TITLE,
'appid': VITE_UNI_APPID,
'description': '',
'versionName': '1.0.0',
'versionCode': '100',
'transformPx': false,
'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
'h5': {
router: {
base: VITE_APP_PUBLIC_BASE,
},
},
/* 5+App特有相关 */
'app-plus': {
usingComponents: true,
nvueStyleCompiler: 'uni-app',
compilerVersion: 3,
compatible: {
ignoreVersion: true,
},
splashscreen: {
alwaysShowBeforeRender: true,
waiting: true,
autoclose: true,
delay: 0,
},
/* 模块配置 */
modules: {},
/* 应用发布信息 */
distribute: {
/* android打包配置 */
android: {
minSdkVersion: 21,
targetSdkVersion: 30,
abiFilters: ['armeabi-v7a', 'arm64-v8a'],
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: {},
/* 图标配置 */
icons: {
android: {
hdpi: 'static/app/icons/72x72.png',
xhdpi: 'static/app/icons/96x96.png',
xxhdpi: 'static/app/icons/144x144.png',
xxxhdpi: 'static/app/icons/192x192.png',
},
ios: {
appstore: 'static/app/icons/1024x1024.png',
ipad: {
'app': 'static/app/icons/76x76.png',
'app@2x': 'static/app/icons/152x152.png',
'notification': 'static/app/icons/20x20.png',
'notification@2x': 'static/app/icons/40x40.png',
'proapp@2x': 'static/app/icons/167x167.png',
'settings': 'static/app/icons/29x29.png',
'settings@2x': 'static/app/icons/58x58.png',
'spotlight': 'static/app/icons/40x40.png',
'spotlight@2x': 'static/app/icons/80x80.png',
},
iphone: {
'app@2x': 'static/app/icons/120x120.png',
'app@3x': 'static/app/icons/180x180.png',
'notification@2x': 'static/app/icons/40x40.png',
'notification@3x': 'static/app/icons/60x60.png',
'settings@2x': 'static/app/icons/58x58.png',
'settings@3x': 'static/app/icons/87x87.png',
'spotlight@2x': 'static/app/icons/80x80.png',
'spotlight@3x': 'static/app/icons/120x120.png',
},
},
},
},
},
/* 快应用特有相关 */
'quickapp': {},
/* 小程序特有相关 */
'mp-weixin': {
appid: VITE_WX_APPID,
setting: {
urlCheck: false,
// 是否启用 ES6 转 ES5
es6: true,
minified: true,
},
optimization: {
subPackages: true,
},
// 是否合并组件虚拟节点外层属性,uni-app 3.5.1+ 开始支持。目前仅支持 style、class 属性。
// 默认不开启(undefined),这里设置为开启。
mergeVirtualHostAttributes: true,
styleIsolation: 'shared',
usingComponents: true,
lazyCodeLoading: 'requiredComponents',
// __usePrivacyCheck__: true,
},
'mp-alipay': {
usingComponents: true,
styleIsolation: 'shared',
optimization: {
subPackages: true,
},
// 解决支付宝小程序开发工具报错 【globalThis is not defined】
compileOptions: {
globalObjectMode: 'enable',
transpile: {
script: {
ignore: ['node_modules/**'],
},
},
},
},
'mp-baidu': {
usingComponents: true,
},
'mp-toutiao': {
usingComponents: true,
},
'uniStatistics': {
enable: false,
},
'vueVersion': '3',
})
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'openapi-ts-request'
export default defineConfig([
{
describe: 'unibest-openapi-test',
schemaPath: 'https://ukw0y1.laf.run/unibest-opapi-test.json',
serversPath: './src/service',
requestLibPath: `import request from '@/http/vue-query';\n import { CustomRequestOptions_ } from '@/http/types';`,
requestOptionsType: 'CustomRequestOptions_',
isGenReactQuery: false,
reactQueryMode: 'vue',
isGenJavaScript: false,
},
])
+222
View File
@@ -0,0 +1,222 @@
{
"name": "iotlcm-app-ui",
"type": "module",
"version": "1.0.0",
"unibest": {
"platforms": [
"h5",
"mp-weixin",
"app"
],
"uiLibrary": "wot-ui-v2",
"loginStrategy": true,
"i18n": true,
"chartLibraries": [
"ucharts"
],
"cliVersion": "4.0.16",
"unibestVersion": "4.4.1",
"unibestUpdateTime": "2026-05-22",
"createdAt": "2026-06-16 15:30:53"
},
"packageManager": "pnpm@10.10.0",
"description": "unibest - 最好的 uniapp 开发模板",
"generate-time": "用户创建项目时生成",
"author": {
"name": "feige996",
"zhName": "菲鸽",
"email": "1020103647@example.com",
"github": "https://github.com/feige996",
"gitee": "https://gitee.com/feige996"
},
"license": "MIT",
"homepage": "https://unibest.tech",
"repository": "https://github.com/feige996/unibest",
"bugs": {
"url": "https://github.com/feige996/unibest/issues",
"url-old": "https://github.com/codercup/unibest/issues"
},
"engines": {
"node": ">=20",
"pnpm": ">=9"
},
"scripts": {
"openapi": "openapi-ts",
"init-husky": "git init && husky",
"init-baseFiles": "node ./scripts/create-base-files.js",
"prepare": "pnpm init-husky & pnpm init-baseFiles",
"predev": "pnpm init-baseFiles",
"predev:app": "pnpm init-baseFiles",
"predev:mp": "pnpm init-baseFiles",
"preinstall": "npx only-allow pnpm",
"uvm": "npx @dcloudio/uvm@latest",
"uvm-rm": "node ./scripts/postupgrade.js",
"bump-version": "node ./scripts/bump-version.js",
"postuvm": "echo upgrade uni-app success!",
"dev:app": "uni -p app",
"dev:app:test": "uni -p app --mode test",
"dev:app:prod": "uni -p app --mode production",
"dev:app-android": "uni -p app-android",
"dev:app-ios": "uni -p app-ios",
"dev:custom": "uni -p",
"dev": "uni",
"dev:test": "uni --mode test",
"dev:prod": "uni --mode production",
"dev:h5": "uni",
"dev:h5:test": "uni --mode test",
"dev:h5:prod": "uni --mode production",
"dev:h5:ssr": "uni --ssr",
"dev:mp": "uni -p mp-weixin",
"dev:mp:test": "uni -p mp-weixin --mode test",
"dev:mp:prod": "uni -p mp-weixin --mode production",
"dev:mp-alipay": "uni -p mp-alipay",
"dev:mp-baidu": "uni -p mp-baidu",
"dev:mp-jd": "uni -p mp-jd",
"dev:mp-kuaishou": "uni -p mp-kuaishou",
"dev:mp-lark": "uni -p mp-lark",
"dev:mp-qq": "uni -p mp-qq",
"dev:mp-toutiao": "uni -p mp-toutiao",
"dev:mp-weixin": "uni -p mp-weixin",
"dev:mp-xhs": "uni -p mp-xhs",
"dev:quickapp-webview": "uni -p quickapp-webview",
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:app": "uni build -p app",
"build:app:test": "uni build -p app --mode test",
"build:app:prod": "uni build -p app --mode production",
"build:app-android": "uni build -p app-android",
"build:app-ios": "uni build -p app-ios",
"build:custom": "uni build -p",
"build:h5": "uni build",
"build:h5:test": "uni build --mode test",
"build:h5:prod": "uni build --mode production",
"build": "uni build",
"build:test": "uni build --mode test",
"build:prod": "uni build --mode production",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"build:mp": "uni build -p mp-weixin",
"build:mp:test": "uni build -p mp-weixin --mode test",
"build:mp:prod": "uni build -p mp-weixin --mode production",
"build:mp-baidu": "uni build -p mp-baidu",
"build:mp-jd": "uni build -p mp-jd",
"build:mp-kuaishou": "uni build -p mp-kuaishou",
"build:mp-lark": "uni build -p mp-lark",
"build:mp-qq": "uni build -p mp-qq",
"build:mp-toutiao": "uni build -p mp-toutiao",
"build:mp-weixin": "uni build -p mp-weixin",
"build:mp-xhs": "uni build -p mp-xhs",
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"upload:changeset": "pnpm changeset && pnpm changeset version",
"upload:mp": "node ./scripts/upload-weixin.js",
"type-check": "vue-tsc --noEmit",
"lint": "eslint",
"lint:fix": "eslint --fix",
"format": "prettier . --write",
"format:check": "prettier . --check"
},
"dependencies": {
"@alova/adapter-uniapp": "^2.0.14",
"@alova/shared": "^1.3.1",
"@changesets/cli": "^2.30.0",
"@dcloudio/uni-app": "3.0.0-4070620250821001",
"@dcloudio/uni-app-harmony": "3.0.0-4070620250821001",
"@dcloudio/uni-app-plus": "3.0.0-4070620250821001",
"@dcloudio/uni-components": "3.0.0-4070620250821001",
"@dcloudio/uni-h5": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-alipay": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-baidu": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-harmony": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-jd": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-lark": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-qq": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-weixin": "3.0.0-4070620250821001",
"@dcloudio/uni-mp-xhs": "3.0.0-4070620250821001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4070620250821001",
"@qiun/ucharts": "2.5.0-20230101",
"@wot-ui/ui": "latest",
"abortcontroller-polyfill": "^1.7.8",
"alova": "^3.3.3",
"dayjs": "1.11.10",
"go-captcha-uni": "^1.0.7",
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"vue": "^3.4.21",
"vue-i18n": "9.1.9",
"vue-router": "4.5.1",
"z-paging": "2.8.7"
},
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-4070620250821001",
"@dcloudio/uni-cli-shared": "3.0.0-4070620250821001",
"@dcloudio/uni-stacktracey": "3.0.0-4070620250821001",
"@dcloudio/vite-plugin-uni": "3.0.0-4070620250821001",
"@iconify-json/carbon": "^1.2.4",
"@iconify/utils": "^3.0.2",
"@types/node": "^20.17.9",
"@uni-helper/eslint-config": "0.5.0",
"@uni-helper/plugin-uni": "0.1.0",
"@uni-helper/uni-env": "0.1.8",
"@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.12",
"@uni-helper/vite-plugin-uni-pages": "0.3.23",
"@uni-helper/vite-plugin-uni-platform": "0.0.5",
"@uni-ku/bundle-optimizer": "v1.3.15-beta.2",
"@uni-ku/root": "1.4.1",
"@unocss/eslint-plugin": "^66.2.3",
"@unocss/preset-legacy-compat": "66.0.0",
"@vitejs/plugin-vue": "^5.1.0",
"@vue/runtime-core": "^3.4.21",
"@vue/tsconfig": "^0.1.3",
"autoprefixer": "^10.4.20",
"cross-env": "^10.0.0",
"enquirer": "^2.4.1",
"eslint": "^9.31.0",
"eslint-plugin-format": "^1.0.1",
"husky": "^9.1.7",
"lint-staged": "^15.2.10",
"miniprogram-api-typings": "^4.1.0",
"miniprogram-ci": "^2.1.26",
"openapi-ts-request": "^1.10.0",
"picocolors": "^1.1.1",
"postcss": "^8.4.49",
"postcss-html": "^1.8.0",
"postcss-scss": "^4.0.9",
"prettier": "3.8.3",
"rollup-plugin-visualizer": "^6.0.3",
"sass": "1.77.8",
"std-env": "^3.9.0",
"typescript": "~5.8.0",
"unocss": "66.0.0",
"unplugin-auto-import": "^20.0.0",
"vite": "5.2.8",
"vite-plugin-restart": "^1.0.0",
"vite-plugin-vue-devtools": "^8.0.5",
"vue-tsc": "^3.0.6"
},
"pnpm": {
"overrides": {
"unconfig": "7.3.2"
}
},
"overrides": {
"unconfig": "7.3.2"
},
"resolutions": {
"bin-wrapper": "npm:bin-wrapper-china",
"unconfig": "7.3.2"
},
"lint-staged": {
"*": "echo 祝菲鸽身体健康"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
import { tabBar } from './src/tabbar/config'
export default defineUniPages({
globalStyle: {
navigationStyle: 'default',
navigationBarTitleText: '山东平台物联卡',
navigationBarBackgroundColor: '#f8f8f8',
navigationBarTextStyle: 'black',
backgroundColor: '#FFFFFF',
},
easycom: {
autoscan: true,
custom: {
'^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
'^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
'z-paging/components/z-paging$1/z-paging$1.vue',
'^wd-(.*)': '@wot-ui/ui/components/wd-$1/wd-$1.vue',
},
},
// tabbar 的配置统一在 “./src/tabbar/config.ts” 文件中
// subPackages: [{
// root: 'pages-demo',
// pages: [],
// }],
tabBar: tabBar as any,
})
+22285
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"setting": {
"es6": true,
"postcss": true,
"minified": true,
"uglifyFileName": false,
"enhance": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"useCompilerPlugins": false,
"minifyWXML": true,
"compileWorklet": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"minifyWXSS": true,
"localPlugins": false,
"disableUseStrict": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"compileType": "miniprogram",
"simulatorPluginLibVersion": {},
"packOptions": {
"ignore": [],
"include": []
},
"appid": "YOUR_WX_APPID",
"editorSetting": {},
"libVersion": "3.16.2"
}
+22
View File
@@ -0,0 +1,22 @@
{
"libVersion": "3.16.2",
"projectname": "app",
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"compileHotReLoad": true,
"useApiHook": true,
"useApiHostProcess": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+154
View File
@@ -0,0 +1,154 @@
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import enquirer from 'enquirer'
import pc from 'picocolors'
const manifestPath = path.resolve(process.cwd(), 'manifest.config.ts')
// 获取是否只是测试运行
const dryRun = process.argv.includes('--dry-run')
/**
* 将语义化版本号根据传递的类型进行递增
* @param {string} version 当前版本号,如 '1.1.0'
* @param {string} type 升级类型: 'patch' | 'minor' | 'major' | 'none'
* @returns {string} 新的版本号
*/
function bumpVersionName(version, type) {
if (type === 'none') return version
const parts = version.split('.')
let major = Number.parseInt(parts[0] || '0', 10)
let minor = Number.parseInt(parts[1] || '0', 10)
let patch = Number.parseInt(parts[2] || '0', 10)
switch (type) {
case 'major':
major += 1; minor = 0; patch = 0;
break;
case 'minor':
minor += 1; patch = 0;
break;
case 'patch':
patch += 1;
break;
}
return `${major}.${minor}.${patch}`
}
async function run() {
const source = fs.readFileSync(manifestPath, 'utf8')
// 匹配 versionCode 和 versionName 的正则表达式,支持键名带有引号的情况,例如 'versionCode': '100'
const versionCodeRegex = /((?:['"])?versionCode(?:['"])?\s*:\s*)(['"])(\d+)\2/
const versionNameRegex = /((?:['"])?versionName(?:['"])?\s*:\s*)(['"])([\d\.]+)\2/
const codeMatch = source.match(versionCodeRegex)
const nameMatch = source.match(versionNameRegex)
if (!codeMatch || !nameMatch) {
console.error(pc.red('✖ [bump-version] 未在 manifest.config.ts 中找到合法的 versionCode 或 versionName'))
process.exit(1)
}
const currentVersionCode = Number.parseInt(codeMatch[3], 10)
const currentVersionName = nameMatch[3]
const nextVersionCode = String(currentVersionCode + 1)
// 1. 检查命令行参数是否有 --type
const typeArgMatch = process.argv.find(arg => arg.startsWith('--type='))
let bumpType = typeArgMatch ? typeArgMatch.split('=')[1] : null
// 2. 环境判定:如果不在交互终端或者是CI环境,但是没有指定类型,则默认只升级 versionCode
const isInteractive = process.stdout.isTTY && !process.env.CI
if (!bumpType) {
if (!isInteractive) {
console.log(pc.yellow('⚠ [bump-version] 非交互环境且未指定参数,默认不修改 versionName'))
bumpType = 'none'
} else {
// 3. 在终端交互式询问用户怎么处理 versionName
console.log('')
console.log(pc.cyan('📦 准备发布新版本'))
console.log(`${pc.gray('当前版本:')} ${pc.bold(currentVersionName)} ${pc.gray(`(v${currentVersionCode})`)}`)
console.log('')
const response = await enquirer.prompt({
type: 'select',
name: 'selectedType',
message: '请选择如何升级版本名称 (versionName)?',
pointer: pc.cyan('❯'),
choices: [
{
message: `${pc.bold('修复')} (Patch) ${pc.gray(currentVersionName)} → ${pc.green(bumpVersionName(currentVersionName, 'patch'))}`,
name: 'patch',
hint: pc.gray('修复Bug、极小的代码安全变动')
},
{
message: `${pc.bold('特性')} (Minor) ${pc.gray(currentVersionName)} → ${pc.cyan(bumpVersionName(currentVersionName, 'minor'))}`,
name: 'minor',
hint: pc.gray('新增功能、向下兼容的API更新')
},
{
message: `${pc.bold('重大')} (Major) ${pc.gray(currentVersionName)} → ${pc.magenta(bumpVersionName(currentVersionName, 'major'))}`,
name: 'major',
hint: pc.gray('重大重构、不兼容的API修改')
},
{
message: `${pc.bold('仅Code')} (None) ${pc.gray('保持 ' + currentVersionName)}`,
name: 'none',
hint: pc.gray('保持名称不变,仅升级构建号(versionCode)')
},
{
message: `${pc.bold('取消')} (Cancel) ${pc.gray('完全不升级版本')}`,
name: 'cancel',
hint: pc.gray('跳过版本修改,直接进入后续打包流程')
},
],
// 如果用户按 ctrl+c 退出
onCancel: () => {
console.log(pc.red('✖ 取消操作并退出编译'))
process.exit(1)
}
})
bumpType = response.selectedType
// 用户选择了完全不升级
if (bumpType === 'cancel') {
console.log('')
console.log(pc.green('✔ 已跳过版本升级操作'))
console.log('')
process.exit(0)
}
}
}
// 4. 根据类型计算下一代版本并进行替换计算
const nextVersionName = bumpVersionName(currentVersionName, bumpType)
let updated = source.replace(versionCodeRegex, `${codeMatch[1]}${codeMatch[2]}${nextVersionCode}${codeMatch[2]}`)
updated = updated.replace(versionNameRegex, `${nameMatch[1]}${nameMatch[2]}${nextVersionName}${nameMatch[2]}`)
// 5. 回填文件内容
if (!dryRun) {
fs.writeFileSync(manifestPath, updated, 'utf8')
}
// 美化成功提示输出
console.log('')
console.log(pc.green(`✔ ${dryRun ? '(模拟运行) ' : ''}版本更新成功!`))
if (bumpType !== 'none') {
console.log(` ${pc.gray('versionName:')} ${pc.strikethrough(pc.gray(currentVersionName))} → ${pc.bold(pc.green(nextVersionName))}`)
} else {
console.log(` ${pc.gray('versionName:')} ${pc.dim(currentVersionName)} (未更改)`)
}
console.log(` ${pc.gray('versionCode:')} ${pc.strikethrough(pc.gray(currentVersionCode))} → ${pc.bold(pc.green(nextVersionCode))}`)
console.log('')
}
run().catch(err => {
console.error(pc.red('✖ [bump-version] 发生错误:'), err)
process.exit(1)
})
+55
View File
@@ -0,0 +1,55 @@
// 基础配置文件生成脚本
// 此脚本用于生成 src/manifest.json 和 src/pages.json 基础文件
// 由于这两个配置文件会被添加到 .gitignore 中,因此需要通过此脚本确保项目能正常运行
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// 获取当前文件的目录路径(替代 CommonJS 中的 __dirname)
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// 最简可运行配置
const manifest = { }
const pages = {
pages: [
{
path: 'pages/index/index',
type: 'home',
style: {
navigationStyle: 'custom',
navigationBarTitleText: '首页',
},
},
{
path: 'pages/me/me',
type: 'page',
style: {
navigationBarTitleText: '我的',
},
},
],
subPackages: [],
}
// 使用修复后的 __dirname 来解析文件路径
const manifestPath = path.resolve(__dirname, '../src/manifest.json')
const pagesPath = path.resolve(__dirname, '../src/pages.json')
// 确保 src 目录存在
const srcDir = path.resolve(__dirname, '../src')
if (!fs.existsSync(srcDir)) {
fs.mkdirSync(srcDir, { recursive: true })
}
const MIN_SIZE = `{ }`.length // 如果只有一个空对象,必定是不对的,需要重新生成
// 如果 src/manifest.json 不存在,就创建它;或者如果文件大小小于等于 MIN_SIZE,也重新创建
if (!fs.existsSync(manifestPath) || fs.statSync(manifestPath).size <= MIN_SIZE) {
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
}
// 如果 src/pages.json 不存在,就创建它;或者如果文件大小小于等于 MIN_SIZE,也重新创建
if (!fs.existsSync(pagesPath) || fs.statSync(pagesPath).size <= MIN_SIZE) {
fs.writeFileSync(pagesPath, JSON.stringify(pages, null, 2))
}
+107
View File
@@ -0,0 +1,107 @@
import { exec } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
/**
* 打开开发者工具
* @param {string} env - 环境,'dev' 或 'build'
* @param {object} options - 配置选项
* @param {string} options.wechatDevtoolsCliPath - 微信开发者工具 CLI 路径
*/
function _openDevTools(env = 'dev', options = {}) {
const { wechatDevtoolsCliPath } = options
const platform = process.platform // darwin, win32, linux
const { UNI_PLATFORM } = process.env // mp-weixin, mp-alipay, mp-lark
const uniPlatformText = UNI_PLATFORM === 'mp-weixin' ? '微信小程序' : UNI_PLATFORM === 'mp-alipay' ? '支付宝小程序' : UNI_PLATFORM === 'mp-lark' ? '抖音小程序' : '小程序'
// 项目路径(构建输出目录),根据环境选择不同目录
const outputDir = env === 'build' ? `dist/build/${UNI_PLATFORM}` : `dist/dev/${UNI_PLATFORM}`
const projectPath = path.resolve(process.cwd(), outputDir)
// 检查构建输出目录是否存在
if (!fs.existsSync(projectPath)) {
console.log(`❌ ${uniPlatformText}构建目录不存在:`, projectPath)
return
}
console.log(`🚀 正在打开${uniPlatformText}开发者工具...`)
// 根据不同操作系统执行不同命令
let command = ''
if (platform === 'darwin') {
// macOS
if (UNI_PLATFORM === 'mp-weixin') {
const cliPath = wechatDevtoolsCliPath || '/Applications/wechatwebdevtools.app/Contents/MacOS/cli'
command = `"${cliPath}" -o "${projectPath}"`
}
else if (UNI_PLATFORM === 'mp-alipay') {
command = `/Applications/小程序开发者工具.app/Contents/MacOS/小程序开发者工具 --p "${projectPath}"`
}
else if (UNI_PLATFORM === 'mp-lark') {
command = `/Applications/抖音开发者工具.app/Contents/MacOS/抖音开发者工具 --p "${projectPath}"`
}
}
else if (platform === 'win32' || platform === 'win64') {
// Windows
if (UNI_PLATFORM === 'mp-weixin') {
const cliPath = wechatDevtoolsCliPath || 'C:\\Program Files (x86)\\Tencent\\微信web开发者工具\\cli.bat'
command = `"${cliPath}" -o "${projectPath}"`
}
}
else {
// Linux 或其他系统
console.log('❌ 当前系统不支持自动打开微信开发者工具')
return
}
exec(command, (error, stdout, stderr) => {
if (error) {
console.log(`❌ 打开${uniPlatformText}开发者工具失败:`, error.message)
if (UNI_PLATFORM === 'mp-weixin') {
console.log('💡 当前使用的微信开发者工具 CLI 命令:', command)
console.log('💡 如果安装位置不同,可以在 env/.env 配置 WECHAT_DEVTOOLS_CLI_PATH 为本机实际 CLI 路径')
}
console.log(`💡 请确保${uniPlatformText}开发者工具服务端口已启用`)
console.log(`💡 可以手动打开${uniPlatformText}开发者工具并导入项目:`, projectPath)
return
}
if (stderr) {
console.log('⚠️ 警告:', stderr)
}
console.log(`✅ ${uniPlatformText}开发者工具已打开`)
if (stdout) {
console.log(stdout)
}
})
}
/**
* 创建 Vite 插件,用于自动打开开发者工具
* @param {object} options - 配置选项
* @param {string} options.mode - 构建模式,'development' 或 'production'
* @param {string} options.wechatDevtoolsCliPath - 微信开发者工具 CLI 路径
*/
export default function openDevTools(options = {}) {
const { mode = 'development', wechatDevtoolsCliPath } = options
// 根据 mode 确定环境:development -> dev, production -> build
const env = mode === 'production' ? 'build' : 'dev'
// 首次构建标记
let isFirstBuild = true
return {
name: 'uni-devtools',
writeBundle() {
if (isFirstBuild && process.env.UNI_PLATFORM?.includes('mp')) {
isFirstBuild = false
_openDevTools(env, { wechatDevtoolsCliPath })
}
},
}
}
+95
View File
@@ -0,0 +1,95 @@
// # 执行 `pnpm upgrade` 后会升级 `uniapp` 相关依赖
// # 在升级完后,会自动添加很多无用依赖,这需要删除以减小依赖包体积
// # 只需要执行下面的命令即可
import { exec } from 'node:child_process'
import { promisify } from 'node:util'
// 日志控制开关,设置为 true 可以启用所有日志输出
const FG_LOG_ENABLE = true
// 将 exec 转换为返回 Promise 的函数
const execPromise = promisify(exec)
// 定义要执行的命令
const dependencies = [
// TODO: 如果不需要某个平台的小程序,请手动删除或注释掉
'@dcloudio/uni-mp-baidu',
'@dcloudio/uni-mp-jd',
'@dcloudio/uni-mp-kuaishou',
'@dcloudio/uni-mp-qq',
'@dcloudio/uni-mp-xhs',
'@dcloudio/uni-quickapp-webview',
]
/**
* 带开关的日志输出函数
* @param {string} message 日志消息
* @param {string} type 日志类型 (log, error)
*/
function log(message, type = 'log') {
if (FG_LOG_ENABLE) {
if (type === 'error') {
console.error(message)
}
else {
console.log(message)
}
}
}
/**
* 卸载单个依赖包
* @param {string} dep 依赖包名
* @returns {Promise<boolean>} 是否成功卸载
*/
async function uninstallDependency(dep) {
try {
log(`开始卸载依赖: ${dep}`)
const { stdout, stderr } = await execPromise(`pnpm un ${dep}`)
if (stdout) {
log(`stdout [${dep}]: ${stdout}`)
}
if (stderr) {
log(`stderr [${dep}]: ${stderr}`, 'error')
}
log(`成功卸载依赖: ${dep}`)
return true
}
catch (error) {
// 单个依赖卸载失败不影响其他依赖
log(`卸载依赖 ${dep} 失败: ${error.message}`, 'error')
return false
}
}
/**
* 串行卸载所有依赖包
*/
async function uninstallAllDependencies() {
log(`开始串行卸载 ${dependencies.length} 个依赖包...`)
let successCount = 0
let failedCount = 0
// 串行执行所有卸载命令
for (const dep of dependencies) {
const success = await uninstallDependency(dep)
if (success) {
successCount++
}
else {
failedCount++
}
// 为了避免命令执行过快导致的问题,添加短暂延迟
await new Promise(resolve => setTimeout(resolve, 100))
}
log(`卸载操作完成: 成功 ${successCount} 个, 失败 ${failedCount} 个`)
}
// 执行串行卸载
uninstallAllDependencies().catch((err) => {
log(`串行卸载过程中出现未捕获的错误: ${err}`, 'error')
})
+306
View File
@@ -0,0 +1,306 @@
/**
* 微信小程序 CLI 上传脚本
*
* 使用方法:
* pnpm upload:mp # 版本号读取 package.json,描述使用最新 Git commit
* pnpm upload:mp --version=1.0.1 # 指定版本号(覆盖 package.json)
* pnpm upload:mp --desc="修复bug" # 指定版本描述(覆盖 Git commit)
* pnpm upload:mp --robot=2 # 指定机器人编号(1-30)
* pnpm upload:mp --version=2.0.0 --desc="重大更新" # 组合使用多个参数
*
* 版本号策略: 命令行参数 > package.json version
* 描述策略: 命令行参数 > Git 最新 commit > 默认时间戳
*
* 注意事项:
* 1. 确保已在微信公众平台开通 "小程序代码上传" 权限
* 2. 确保私钥文件存在(private.${appid}.key),并且配置了上传IP白名单
* 3. 上传前会自动执行 build:mp:prod 构建 并跳过打开开发者工具
* 4. 秘钥文件的appid(VITE_WX_APPID)需要与微信公众平台的小程序appid一致
*/
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import ci from 'miniprogram-ci'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT_DIR = path.resolve(__dirname, '..')
// 从 package.json 读取版本号
function getPackageVersion() {
try {
const pkgPath = path.resolve(ROOT_DIR, 'package.json')
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
return pkg.version || '1.0.0'
}
catch {
return '1.0.0'
}
}
// 获取最新的 Git commit 信息
function getGitCommitMessage() {
try {
// 获取最新 commit 的作者和标题
const message = execSync('git log -1 --pretty="%an: %s"', {
cwd: ROOT_DIR,
encoding: 'utf-8',
}).trim()
return message || null
}
catch {
return null
}
}
// 生成默认描述
function getDefaultDesc() {
// 优先使用 Git commit 信息
const gitMessage = getGitCommitMessage()
if (gitMessage) {
return gitMessage
}
// 回退到时间戳
return `上传于 ${new Date().toLocaleString('zh-CN')}`
}
// 解析命令行参数
function parseArgs() {
const args = process.argv.slice(2)
const params = {
version: null, // 稍后设置,优先级:命令行 > package.json
desc: null, // 稍后设置,优先级:命令行 > Git commit > 默认
robot: 1, // 机器人编号 1-30
}
args.forEach((arg) => {
if (arg.startsWith('--version=')) {
params.version = arg.split('=')[1]
}
else if (arg.startsWith('--desc=')) {
params.desc = arg.split('=')[1]
}
else if (arg.startsWith('--robot=')) {
params.robot = Number.parseInt(arg.split('=')[1], 10)
}
})
// 如果命令行没有指定版本号,则读取 package.json
if (!params.version) {
params.version = getPackageVersion()
}
// 如果命令行没有指定描述,则读取 Git commit 或使用默认
if (!params.desc) {
params.desc = getDefaultDesc()
}
return params
}
// 读取环境变量
function loadEnvFile(mode = 'production') {
const envPath = path.resolve(ROOT_DIR, 'env', `.env.${mode}`)
const defaultEnvPath = path.resolve(ROOT_DIR, 'env', '.env')
const localEnvPath = path.resolve(ROOT_DIR, 'env', '.env.local')
const envContent = {}
// 先读取默认 .env
if (fs.existsSync(defaultEnvPath)) {
const content = fs.readFileSync(defaultEnvPath, 'utf-8')
content.split('\n').forEach((line) => {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=')
if (key) {
envContent[key.trim()] = valueParts.join('=').trim().replace(/^['"]|['"]$/g, '')
}
}
})
}
// 再读取对应模式的 .env 文件(会覆盖默认值)
if (fs.existsSync(localEnvPath)) {
const content = fs.readFileSync(localEnvPath, 'utf-8')
content.split('\n').forEach((line) => {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=')
if (key) {
envContent[key.trim()] = valueParts.join('=').trim().replace(/^['"]|['"]$/g, '')
}
}
})
}
if (fs.existsSync(envPath)) {
const content = fs.readFileSync(envPath, 'utf-8')
content.split('\n').forEach((line) => {
const trimmed = line.trim()
if (trimmed && !trimmed.startsWith('#')) {
const [key, ...valueParts] = trimmed.split('=')
if (key) {
envContent[key.trim()] = valueParts.join('=').trim().replace(/^['"]|['"]$/g, '')
}
}
})
}
return envContent
}
// 获取私钥路径
function getPrivateKeyPath(appid) {
const configuredPath = envValue('WECHAT_UPLOAD_PRIVATE_KEY_PATH', 'MINIPROGRAM_UPLOAD_PRIVATE_KEY_PATH')
if (configuredPath) {
const privateKeyPath = path.isAbsolute(configuredPath)
? configuredPath
: path.resolve(ROOT_DIR, configuredPath)
if (fs.existsSync(privateKeyPath)) {
return privateKeyPath
}
throw new Error(`配置的微信小程序上传密钥文件不存在:${privateKeyPath}`)
}
// 查找私钥文件
const keyPatterns = [
`private.${appid}.key`,
'private.key',
]
for (const pattern of keyPatterns) {
const keyPath = path.resolve(ROOT_DIR, pattern)
if (fs.existsSync(keyPath)) {
return keyPath
}
}
throw new Error(`未找到私钥文件,请确保项目根目录存在 private.${appid}.key 文件`)
}
function envValue(...keys) {
for (const key of keys) {
const value = process.env[key]
if (typeof value === 'string' && value.trim()) {
return value.trim().replace(/^['"]|['"]$/g, '')
}
}
return ''
}
// 主函数
async function main() {
console.log('\n🚀 开始微信小程序上传流程...\n')
const params = parseArgs()
const env = loadEnvFile('production')
Object.assign(process.env, env)
const appid = env.VITE_WX_APPID
if (!appid) {
throw new Error('未找到 VITE_WX_APPID 环境变量,请检查 env/.env 文件')
}
console.log(`📱 AppID: ${appid}`)
console.log(`📌 版本号: ${params.version}`)
console.log(`📝 版本描述: ${params.desc}`)
console.log(`🤖 机器人编号: ${params.robot}`)
// 获取私钥路径
const privateKeyPath = getPrivateKeyPath(appid)
console.log(`🔑 私钥路径: ${privateKeyPath}`)
// 构建小程序(跳过自动打开开发者工具)
console.log('\n📦 正在构建小程序...(跳过自动打开开发者工具)\n')
try {
execSync('pnpm build:mp:prod', {
cwd: ROOT_DIR,
stdio: 'inherit',
env: {
...process.env,
SKIP_OPEN_DEVTOOLS: 'true', // 上传时跳过打开开发者工具
},
})
}
catch (error) {
console.error('❌ 构建失败:', error.message)
process.exit(1)
}
// 小程序代码目录
const projectPath = path.resolve(ROOT_DIR, 'dist', 'build', 'mp-weixin')
if (!fs.existsSync(projectPath)) {
throw new Error(`构建产物不存在: ${projectPath}`)
}
console.log(`📂 项目路径: ${projectPath}`)
console.log('\n⬆️ 正在上传到微信服务器...\n')
// 创建项目实例
const project = new ci.Project({
appid,
type: 'miniProgram',
projectPath,
privateKeyPath,
ignores: ['node_modules/**/*'],
})
try {
// 上传代码
const uploadResult = await ci.upload({
project,
version: params.version,
desc: params.desc,
robot: params.robot,
setting: {
es6: true,
es7: true,
minify: true,
autoPrefixWXSS: true,
minifyWXML: true,
minifyWXSS: true,
minifyJS: true,
},
onProgressUpdate: (task) => {
if (task._status === 'done') {
console.log(` ✅ ${task._msg}`)
}
},
})
console.log('\n✅ 上传成功!')
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
console.log(` 📌 版本号: ${params.version}`)
console.log(` 📝 描述: ${params.desc}`)
console.log(` 🤖 机器人: ${params.robot}`)
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
console.log('\n📋 下一步操作:')
console.log(' 1. 登录微信公众平台: https://mp.weixin.qq.com')
console.log(' 2. 进入 "管理 -> 版本管理"')
console.log(' 3. 在 "开发版本" 中找到刚上传的版本')
console.log(' 4. 点击 "选为体验版" 按钮\n')
return uploadResult
}
catch (error) {
console.error('\n❌ 上传失败:', error.message)
if (error.message.includes('privateKeyPath')) {
console.log('\n💡 提示: 请确保已在微信公众平台配置代码上传密钥')
console.log(' 1. 登录微信公众平台')
console.log(' 2. 进入 "开发 -> 开发设置"')
console.log(' 3. 在 "小程序代码上传" 区域生成并下载密钥')
console.log(' 4. 在 "小程序代码上传" 区域配置上传IP白名单')
}
process.exit(1)
}
}
main().catch((error) => {
console.error('❌ 执行出错:', error)
process.exit(1)
})
+45
View File
@@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
const root = process.cwd()
const dataPath = resolve(root, 'src/pages/message/data.ts')
const indexPath = resolve(root, 'src/pages/message/index.vue')
const source = readFileSync(dataPath, 'utf8')
const indexSource = readFileSync(indexPath, 'utf8')
const forbiddenWords = ['代理', '未来', '敬请期待', 'agent', 'future']
const forbiddenRoutePatterns = [
/url\s*:/,
/path\s*:/,
/route\s*:/,
/navigateTo/,
/switchTab/,
/redirectTo/,
/reLaunch/,
]
for (const word of forbiddenWords) {
if (source.includes(word) || indexSource.includes(word)) {
throw new Error(`消息页默认内容包含禁用词:${word}`)
}
}
for (const pattern of forbiddenRoutePatterns) {
if (source.match(pattern) || indexSource.match(pattern)) {
throw new Error(`消息页一级页面不应包含跳转配置或跳转调用:${pattern}`)
}
}
const groupCount = (source.match(/^\s{4}key:\s*'(system|card)',/gm) || []).length
if (groupCount !== 2) {
throw new Error(`消息页应只保留 2 个用户侧消息分类,当前为 ${groupCount} 个`)
}
for (const required of ['系统消息', '卡片消息', '端午假期服务时间调整通知', '尾号 8821 流量不足']) {
if (!source.includes(required)) {
throw new Error(`消息页缺少来自暂存设计的用户侧内容:${required}`)
}
}
console.log('message page scope checked')
+62
View File
@@ -0,0 +1,62 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import process from 'node:process'
const root = process.cwd()
const expectedPages = [
['business', 'renew', '套餐续费确认'],
['business', 'change', '套餐变更确认'],
['business', 'addon', '订购加油包'],
['business', 'diagnosis', '智能诊断'],
['business', 'workOrder', '工单列表'],
['business', 'workOrderCreate', '提交工单'],
['business', 'workOrderDetail', '工单详情'],
['card', 'detail', '卡片详情'],
['card', 'packageDetail', '套餐详情'],
['message', 'systemDetail', '系统消息详情'],
['message', 'cardDetail', '卡片消息详情'],
['user', 'profile', '账号资料'],
['user', 'realName', '实名信息'],
['user', 'notification', '通知设置'],
['user', 'feedback', '帮助与反馈'],
['user', 'contact', '联系客服'],
['user', 'agreement', '协议与隐私'],
['user', 'about', '关于我们'],
]
for (const [owner, name, title] of expectedPages) {
const file = resolve(root, `src/pages/${owner}/views/${name}/index.vue`)
if (!existsSync(file)) {
throw new Error(`缺少子界面文件:src/pages/${owner}/views/${name}/index.vue`)
}
const source = readFileSync(file, 'utf8')
if (!source.includes('definePage({')) {
throw new Error(`子界面缺少 definePage:${file}`)
}
if (!source.includes(`navigationBarTitleText: '${title}'`)) {
throw new Error(`子界面标题不匹配:${file},期望 ${title}`)
}
}
const entryFiles = [
'src/pages/business/index.vue',
'src/pages/card/components/CardList.vue',
'src/pages/card/components/PackageList.vue',
'src/pages/message/components/MessageList.vue',
'src/pages/user/components/UserServiceCells.vue',
'src/pages/user/components/UserSupportCells.vue',
]
for (const relative of entryFiles) {
const source = readFileSync(resolve(root, relative), 'utf8')
if (!source.includes('RouterPath')) {
throw new Error(`入口未使用 RouterPath 跳转:${relative}`)
}
if (!source.includes('navigateTo')) {
throw new Error(`入口未调用 navigateTo:${relative}`)
}
}
console.log('sub pages checked')
+37
View File
@@ -0,0 +1,37 @@
/**
* @description 通过 vite 自定义条件动态导入 eruda
* @description Eruda 配置参考 https://eruda.liriliri.io/zh/docs/
* @param {object} options
* @param {boolean} [options.open] - 是否开启 eruda
* @param {object} [options.erudaOptions] - eruda 配置
* @param {string} [options.erudaUrl] - eruda 地址
*/
export default function vitePluginEruda(options = {}) {
const { open = true, erudaOptions = {}, erudaUrl = 'https://cdn.jsdelivr.net/npm/eruda' } = options
return {
name: 'vite-plugin-eruda',
transformIndexHtml(html) {
const tags = [
{
tag: 'script',
attrs: {
src: erudaUrl,
},
injectTo: 'head',
},
{
tag: 'script',
children: `eruda.init(${JSON.stringify(erudaOptions)});`,
injectTo: 'head',
},
]
if (!open) {
return html
}
return { html, tags }
},
}
}
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
import FgTabbar from '@/tabbar/index.vue'
import { isPageTabbar } from './tabbar/store'
import { currRoute } from './utils'
const isCurrentPageTabbar = ref(true)
onShow(() => {
const { path } = currRoute()
// “蜡笔小开心”提到本地是 '/pages/index/index',线上是 '/' 导致线上 tabbar 不见了
// 所以这里需要判断一下,如果是 '/' 就当做首页,也要显示 tabbar
if (path === '/') {
isCurrentPageTabbar.value = true
} else {
isCurrentPageTabbar.value = isPageTabbar(path)
}
})
const helloKuRoot = ref('Hello AppKuVue')
const exposeRef = ref('this is form app.Ku.vue')
defineExpose({
exposeRef,
})
</script>
<template>
<view class="app-root">
<!-- 这个先隐藏了,知道这样用就行 -->
<view class="hidden text-center"> {{ helloKuRoot }},这里可以配置全局的东西 </view>
<KuRootView />
<FgTabbar v-if="isCurrentPageTabbar" />
</view>
</template>
<style lang="scss" scoped>
.app-root {
box-sizing: border-box;
width: 100%;
min-height: 100vh;
background: #f6f8fb;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { getCurrentInstance, onMounted, onUnmounted } from 'vue'
import { navigateToInterceptor } from '@/router/interceptor'
import { tabbarStore } from '@/tabbar/store'
import { permission } from '@/router/permission'
const { proxy } = (getCurrentInstance() || {}) as any
const router = proxy?.$router
router && permission.install(router)
onLaunch((options: any) => {
// console.log('App.vue onLaunch', options)
})
onShow((options: any) => {
// console.log('App.vue onShow', options)
// 处理直接进入页面路由的情况:如h5直接输入路由、微信小程序分享后进入等
// https://github.com/unibest-tech/unibest/issues/192
if (options?.path) {
navigateToInterceptor.invoke({ url: `/${options.path}`, query: options.query })
} else {
navigateToInterceptor.invoke({ url: '/' })
}
})
onHide(() => {
// console.log('App Hide')
})
// #ifdef H5
function syncTabbarWhenPageVisible() {
if (document.visibilityState === 'visible') {
tabbarStore.syncCurIdxByCurrentPageAsync()
}
}
onMounted(() => {
document.addEventListener('visibilitychange', syncTabbarWhenPageVisible)
window.addEventListener('pageshow', syncTabbarWhenPageVisible)
})
onUnmounted(() => {
document.removeEventListener('visibilitychange', syncTabbarWhenPageVisible)
window.removeEventListener('pageshow', syncTabbarWhenPageVisible)
})
// #endif
</script>
<style lang="scss"></style>
+61
View File
@@ -0,0 +1,61 @@
import type {
BalanceRechargeMockPayParams,
BalanceRechargeRefundParams,
BalanceRechargeStatusParams,
BalanceRechargeStatusRes,
BalanceRechargeSubmitParams,
BalanceRechargeSubmitRes,
} from './types/balance'
import type { AppOrder } from './types/order'
import { http } from '@/http/http'
export type {
BalanceRechargeMockPayParams,
BalanceRechargeRefundParams,
BalanceRechargeStatusParams,
BalanceRechargeStatusRes,
BalanceRechargeSubmitParams,
BalanceRechargeSubmitRes,
} from './types/balance'
export function balanceRechargeSubmit(data: BalanceRechargeSubmitParams) {
return http.post<BalanceRechargeSubmitRes>(
'/balance/recharge-submit',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function balanceRechargeStatus(data: BalanceRechargeStatusParams) {
return http.get<BalanceRechargeStatusRes>('/balance/recharge-status', data, undefined, {
hideErrorToast: true,
})
}
export function balanceRechargeMockPay(data: BalanceRechargeMockPayParams) {
return http.post<BalanceRechargeStatusRes>(
'/balance/recharge-mock-pay',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function balanceRechargeRefund(data: BalanceRechargeRefundParams) {
return http.post<AppOrder>(
'/balance/recharge-refund',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
+203
View File
@@ -0,0 +1,203 @@
import type {
AllocateBatchPrepareParams,
AllocateBatchRes,
AllocateBatchSubmitParams,
AllocateTargetListParams,
AllocateTargetListRes,
BindCardParams,
CardLifecycleParams,
CardLifecycleRes,
CardLifecycleStatusParams,
CardListParams,
CardListRes,
CardRecognitionRes,
CardQueryRes,
ChangePrepareParams,
ChangePrepareRes,
ChangeSubmitParams,
ChangeSubmitRes,
PackageDetailRes,
PackageListParams,
PackageListRes,
PublicPackageDetailParams,
PublicQueryCardParams,
RenewBatchPrepareParams,
RenewBatchSubmitParams,
RenewPrepareParams,
RenewPrepareRes,
RenewSubmitParams,
RenewSubmitRes,
RestartCardParams,
RestartCardRes,
} from './types/card'
import { http } from '@/http/http'
import { getEnvBaseUrl } from '@/utils/index'
const CARD_REQUEST_OPTIONS = {
timeout: 15000,
hideErrorToast: true,
} as const
export type {
AllocateBatchRes,
AllocateTarget,
AppCard,
AppPackage,
AppRenewOption,
CardLifecycleRes,
ChangePackageOption,
ChangePrepareRes,
ChangeSubmitRes,
RenewPackageOption,
RenewPrepareRes,
RenewSubmitRes,
} from './types/card'
export function publicQueryCard(data: PublicQueryCardParams) {
return http.post<CardQueryRes>('/card/public-query', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function recognizeCardImage(filePath: string) {
return new Promise<CardRecognitionRes>((resolve, reject) => {
uni.uploadFile({
url: `${getEnvBaseUrl()}/card/recognize`,
filePath,
name: 'file',
timeout: 30000,
success: (response) => {
let payload: { code?: number, message?: string, data?: CardRecognitionRes }
try {
payload = JSON.parse(String(response.data || '{}'))
}
catch {
reject({ message: '图片识别响应格式错误' })
return
}
if (response.statusCode < 200 || response.statusCode >= 300 || Number(payload.code) !== 0) {
reject({ message: payload.message || '图片识别失败' })
return
}
resolve(payload.data || {
code: 'RECOGNITION_FAILED',
message: '图片识别结果为空',
cardType: 'iccid',
cardValue: '',
confidence: 0,
candidates: [],
})
},
fail: (error) => {
reject({ message: error.errMsg || '图片上传失败,请检查网络后重试' })
},
})
})
}
export function getCardList(params: CardListParams) {
return http.get<CardListRes>('/card/list', params, undefined, CARD_REQUEST_OPTIONS)
}
export function getPackageList(params: PackageListParams) {
return http.get<PackageListRes>('/card/package-list', params, undefined, CARD_REQUEST_OPTIONS)
}
export function publicPackageDetail(data: PublicPackageDetailParams) {
return http.post<PackageDetailRes>(
'/card/public-package-detail',
data,
undefined,
undefined,
CARD_REQUEST_OPTIONS,
)
}
export function getCardDetail(cardValue: string) {
return http.get<CardQueryRes>('/card/detail', { cardValue }, undefined, CARD_REQUEST_OPTIONS)
}
export function bindCard(data: BindCardParams) {
return http.post<CardQueryRes>('/card/bind', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function restartCard(data: RestartCardParams) {
return http.post<RestartCardRes>('/card/restart', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function stopCard(data: RestartCardParams) {
return http.post<CardLifecycleRes>('/card/stop', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function startCard(data: RestartCardParams) {
return http.post<CardLifecycleRes>('/card/start', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function startDataService(data: RestartCardParams) {
return lifecycleBatchSubmit({ cardValue: data.cardValue, operation: 'dataStart', remark: data.remark })
}
export function stopDataService(data: RestartCardParams) {
return lifecycleBatchSubmit({ cardValue: data.cardValue, operation: 'dataStop', remark: data.remark })
}
export function lifecycleBatchSubmit(data: CardLifecycleParams) {
return http.post<CardLifecycleRes>(
'/card/lifecycle-batch-submit',
data,
undefined,
undefined,
CARD_REQUEST_OPTIONS,
)
}
export function lifecycleBatchStatus(data: CardLifecycleStatusParams) {
return http.get<CardLifecycleRes>('/card/lifecycle-batch-status', data, undefined, CARD_REQUEST_OPTIONS)
}
export function renewPrepare(data: RenewPrepareParams) {
return http.post<RenewPrepareRes>('/card/renew-prepare', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function renewBatchPrepare(data: RenewBatchPrepareParams) {
return http.post<RenewPrepareRes>('/card/renew-batch-prepare', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function renewSubmit(data: RenewSubmitParams) {
return http.post<RenewSubmitRes>('/card/renew-submit', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function renewBatchSubmit(data: RenewBatchSubmitParams) {
return http.post<RenewSubmitRes>('/card/renew-batch-submit', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function getAllocateTargets(params: AllocateTargetListParams) {
return http.get<AllocateTargetListRes>('/card/allocate-targets', params, undefined, CARD_REQUEST_OPTIONS)
}
export function allocateBatchPrepare(data: AllocateBatchPrepareParams) {
return http.post<AllocateBatchRes>(
'/card/allocate-batch-prepare',
data,
undefined,
undefined,
CARD_REQUEST_OPTIONS,
)
}
export function allocateBatchSubmit(data: AllocateBatchSubmitParams) {
return http.post<AllocateBatchRes>(
'/card/allocate-batch-submit',
data,
undefined,
undefined,
CARD_REQUEST_OPTIONS,
)
}
export function changePrepare(data: ChangePrepareParams) {
return http.post<ChangePrepareRes>('/card/change-prepare', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
export function changeSubmit(data: ChangeSubmitParams) {
return http.post<ChangeSubmitRes>('/card/change-submit', data, undefined, undefined, CARD_REQUEST_OPTIONS)
}
+27
View File
@@ -0,0 +1,27 @@
import type {
CustomerDetailParams,
CustomerDetailRes,
CustomerListParams,
CustomerListRes,
} from './types/customer'
import { http } from '@/http/http'
export type {
AppCustomer,
AppCustomerDetail,
AppCustomerType,
CustomerDetailParams,
CustomerListParams,
} from './types/customer'
export function getCustomerList(params: CustomerListParams) {
return http.get<CustomerListRes>('/customer/list', params, undefined, {
hideErrorToast: true,
})
}
export function getCustomerDetail(params: CustomerDetailParams) {
return http.get<CustomerDetailRes>('/customer/detail', params, undefined, {
hideErrorToast: true,
})
}
+44
View File
@@ -0,0 +1,44 @@
import type {
DiagnosisFlowParams,
DiagnosisFlowRes,
DiagnosisInfoParams,
DiagnosisInfoRes,
} from './types/diagnosis'
import { http } from '@/http/http'
export type {
DiagnosisFlowParams,
DiagnosisFlowRes,
DiagnosisInfoItem,
DiagnosisInfoParams,
DiagnosisInfoRes,
DiagnosisModuleItem,
DiagnosisPackageItem,
DiagnosisRateLimitApnItem,
DiagnosisRateLimitPccItem,
DiagnosisSessionItem,
} from './types/diagnosis'
export function getDiagnosisInfo(data: DiagnosisInfoParams) {
return http.post<DiagnosisInfoRes>('/diagnosis/info', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getOpenDiagnosisInfo(data: DiagnosisInfoParams & { tenantId?: string }) {
return http.post<DiagnosisInfoRes>('/open/diagnosis/info', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getDiagnosisFlow(data: DiagnosisFlowParams) {
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getOpenDiagnosisFlow(data: DiagnosisFlowParams & { tenantId?: string }) {
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
hideErrorToast: true,
})
}
+16
View File
@@ -0,0 +1,16 @@
import { http } from '@/http/alova'
export interface IFoo {
id: number
name: string
}
export function foo() {
return http.Get<IFoo>('/foo', {
params: {
name: '菲鸽',
page: 1,
pageSize: 10,
},
})
}
+43
View File
@@ -0,0 +1,43 @@
import { http } from '@/http/http'
export interface IFoo {
id: number
name: string
}
export function foo() {
return http.Get<IFoo>('/foo', {
params: {
name: '菲鸽',
page: 1,
pageSize: 10,
},
})
}
export interface IFooItem {
id: string
name: string
}
/** GET 请求 */
export async function getFooAPI(name: string) {
return await http.get<IFooItem>('/foo', { name })
}
/** GET 请求;支持 传递 header 的范例 */
export function getFooAPI2(name: string) {
return http.get<IFooItem>('/foo', { name }, { 'Content-Type-100': '100' })
}
/** POST 请求 */
export function postFooAPI(name: string) {
return http.post<IFooItem>('/foo', { name })
}
/** POST 请求;需要传递 query 参数的范例;微信小程序经常有同时需要query参数和body参数的场景 */
export function postFooAPI2(name: string) {
return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 })
}
/** POST 请求;支持 传递 header 的范例 */
export function postFooAPI3(name: string) {
return http.post<IFooItem>('/foo', { name }, { a: 1, b: 2 }, { 'Content-Type-100': '100' })
}
+152
View File
@@ -0,0 +1,152 @@
import type {
IAuthLoginRes,
ICheckSideCaptchaForm,
IForgotPasswordForm,
ILoginForm,
IRegisterForm,
ISendSmsForm,
ISendSmsRes,
ISideCaptcha,
IUpdateInfo,
IUserInfoRes,
IWechatBindForm,
IWechatBindPhoneForm,
IWechatBindPhoneRes,
IWechatBindRes,
IWechatLoginForm,
IWechatLoginRes,
} from './types/login'
import { http } from '@/http/http'
export type { IForgotPasswordForm, ILoginForm, IRegisterForm }
const AUTH_REQUEST_OPTIONS = {
timeout: 15000,
hideErrorToast: true,
} as const
export function getSideCaptcha() {
return http.post<ISideCaptcha>(
'/auth/side-captcha',
undefined,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function checkSideCaptcha(data: ICheckSideCaptchaForm) {
return http.post<{ success: boolean }>(
'/auth/check-side-captcha',
data,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function sendSms(data: ISendSmsForm) {
return http.post<ISendSmsRes>('/auth/sms', data, undefined, undefined, AUTH_REQUEST_OPTIONS)
}
export function login(loginForm: ILoginForm) {
return http.post<IAuthLoginRes>(
'/auth/login',
loginForm,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function wechatLogin(data: IWechatLoginForm) {
return http.post<IWechatLoginRes>(
'/auth/wechat-login',
data,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function wechatBindPhone(data: IWechatBindPhoneForm) {
return http.post<IWechatBindPhoneRes>(
'/auth/wechat-bind-phone',
data,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function register(registerForm: IRegisterForm) {
return http.post<IAuthLoginRes>(
'/auth/register',
registerForm,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function forgotPassword(data: IForgotPasswordForm) {
return http.post<{ success: boolean }>(
'/auth/forgot-password',
data,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function refreshToken(_refreshToken: string) {
return Promise.reject(new Error('当前 APP 接口使用单 token 模式,暂不支持刷新 token'))
}
export function getUserInfo() {
return http.get<IUserInfoRes>('/user/info', undefined, undefined, AUTH_REQUEST_OPTIONS)
}
export function logout() {
return Promise.resolve()
}
export function updateInfo(data: IUpdateInfo) {
return http.post<IUserInfoRes>('/user/info', data, undefined, undefined, AUTH_REQUEST_OPTIONS)
}
export function bindWechat(data: IWechatBindForm) {
return http.post<IWechatBindRes>(
'/user/wechat/bind',
data,
undefined,
undefined,
AUTH_REQUEST_OPTIONS,
)
}
export function updateUserPassword() {
return Promise.reject(new Error('请使用找回密码流程重置密码'))
}
export interface AppWechatLoginCodeRes {
code: string
errMsg?: string
}
export function getWxCode() {
return new Promise<AppWechatLoginCodeRes>((resolve, reject) => {
if (typeof wx !== 'undefined' && typeof wx.login === 'function') {
wx.login({
success: res => resolve({ code: res.code || '', errMsg: res.errMsg }),
fail: err => reject(new Error(err.errMsg || '微信登录失败')),
})
return
}
uni.login({
provider: 'weixin',
success: res => resolve({ code: res.code || '', errMsg: res.errMsg }),
fail: err => reject(new Error(err.errMsg)),
})
})
}
+78
View File
@@ -0,0 +1,78 @@
import type { MessageCenterParams, MessageCenterRes } from './types/message'
import { http } from '@/http/http'
export interface AppMessage {
id: number
messageId: number
messageLogId: number
messageType: number
messageTypeText?: string
title: string
content: string
link?: string
isRead: number
sendTime?: string
createdAt?: string
updatedAt?: string
}
export interface AppMessageListParams {
page: number
pageSize: number
isRead?: '' | '0' | '1'
messageType?: number
keyword?: string
}
export interface AppMessageListRes {
items: AppMessage[]
total: number
page?: number
pageSize?: number
}
export interface AppMessageDetailParams {
messageId: number
}
export interface AppMessageDetailRes {
message: AppMessage
}
export interface AppMessageReadParams {
ids: number[]
}
export function getMessageList(params: AppMessageListParams) {
return http.get<AppMessageListRes>('/message/list', params, undefined, {
hideErrorToast: true,
})
}
export async function getMessageCenter(params: MessageCenterParams = {}): Promise<MessageCenterRes> {
const unreadRes = await getMessageList({
page: 1,
pageSize: 1,
isRead: '0',
keyword: params.keyword,
group: params.group,
} as AppMessageListParams)
return {
groups: [],
totalUnread: Number(unreadRes.total || 0),
warnSummary: undefined,
}
}
export function getMessageDetail(params: AppMessageDetailParams) {
return http.get<AppMessageDetailRes>('/message/detail', params, undefined, {
hideErrorToast: true,
})
}
export function readMessages(data: AppMessageReadParams) {
return http.post<void>('/message/read', data, undefined, undefined, {
hideErrorToast: true,
})
}
+21
View File
@@ -0,0 +1,21 @@
import type {
OrderDetailParams,
OrderDetailRes,
OrderListParams,
OrderListRes,
} from './types/order'
import { http } from '@/http/http'
export type { AppOrder, AppOrderItem, OrderDetailParams, OrderListParams } from './types/order'
export function getOrderList(params: OrderListParams) {
return http.get<OrderListRes>('/order/list', params, undefined, {
hideErrorToast: true,
})
}
export function getOrderDetail(params: OrderDetailParams) {
return http.get<OrderDetailRes>('/order/detail', params, undefined, {
hideErrorToast: true,
})
}
+19
View File
@@ -0,0 +1,19 @@
export interface AccessCodesResponse {
code?: number | string
data?:
| string[]
| {
data?: string[]
}
}
export function normalizeAccessCodesResponse(body?: string[] | AccessCodesResponse | null) {
if (Array.isArray(body))
return body
if (Array.isArray(body?.data))
return body.data
const nested = body?.data
if (nested && !Array.isArray(nested) && Array.isArray(nested.data))
return nested.data
return []
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { normalizeAccessCodesResponse } from './system-normalize'
describe('normalizeAccessCodesResponse', () => {
it('supports xiuAdmin wrapped access code response', () => {
expect(
normalizeAccessCodesResponse({
code: 0,
data: {
data: ['cpm:iot:tSimCardInfo:view', 'cpm:iot:tSimCardRenew:input:prepare'],
},
}),
).toEqual(['cpm:iot:tSimCardInfo:view', 'cpm:iot:tSimCardRenew:input:prepare'])
})
it('keeps compatibility with direct array response', () => {
expect(
normalizeAccessCodesResponse({
code: 0,
data: ['cpc:current:user'],
}),
).toEqual(['cpc:current:user'])
})
})
+15
View File
@@ -0,0 +1,15 @@
import type { AccessCodesResponse } from './system-normalize'
import { http } from '@/http/http'
import { normalizeAccessCodesResponse } from './system-normalize'
export interface AccessCodesParams {
isCache?: boolean
}
export { normalizeAccessCodesResponse }
export function getAccessCodes(params?: AccessCodesParams) {
return http.get<string[] | AccessCodesResponse>('/system/auth/codes', params, undefined, {
hideErrorToast: true,
}).then(normalizeAccessCodesResponse)
}
+61
View File
@@ -0,0 +1,61 @@
export interface BalanceRechargeSubmitParams {
amount: number
buyerNote?: string
payProvider?: 'wechat' | 'mock'
payScene?: 'h5' | 'mini'
wxCode?: string
}
export interface BalanceRechargeSubmitRes {
orderId: number
orderSn: string
totalAmount: number
priceText: string
orderStatus: number
payStatus: number
status: string
paymentExpireAt?: string
paymentRemainingSeconds?: number
paymentExpired?: boolean
payProvider: string
payUrl: string
payMethod?: string
payGateway?: string
payParams?: Record<string, string>
}
export interface BalanceRechargeStatusParams {
orderSn: string
payScene?: 'h5' | 'mini'
wxCode?: string
}
export interface BalanceRechargeMockPayParams {
orderSn: string
}
export interface BalanceRechargeRefundParams {
orderSn: string
refundReason?: string
}
export interface BalanceRechargeStatusRes {
orderId: number
orderSn: string
totalAmount: number
priceText: string
orderStatus: number
payStatus: number
status: string
paymentExpireAt?: string
paymentRemainingSeconds?: number
paymentExpired?: boolean
balanceAfter: number
balanceAfterText: string
payProvider: string
payTradeNo: string
payUrl: string
payMethod?: string
payGateway?: string
payParams?: Record<string, string>
}
+394
View File
@@ -0,0 +1,394 @@
export interface AppCard {
id?: number
iccid: string
msisdn?: string
imsi?: string
imei?: string
regionCode?: string
deptName?: string
currentPackageCode?: string
packageName?: string
packageType?: number
categoryCode?: string
categoryName?: string
packageStatus?: number
bossCardStatus?: number
abnormalReason?: string
abnormalReasons?: string[]
cardType?: number
netModel?: number
onlineStatus?: string | number
deviceStatus?: string | number
imeiStatus?: string | number
regionLimitStatus?: string | number
apnName?: string
apnId?: string
rat?: string
ipv4?: string
ipv6?: string
ipv6Prefix?: string
sessionCreateTime?: string
stopReason?: string
packageTotalFlowMb?: number
usedFlowMb?: number
remainFlowMb?: number
usedFlowPercent?: number
yesterdayFlowStatDate?: string
yesterdayDeptUsedFlowMb?: number
yesterdayDeptSms?: number
yesterdayDeptVoiceMin?: number
flowPkgBeginAt?: string
flowPkgEndAt?: string
dataServiceStatus?: number
packageTotalSms?: number
usedSms?: number
smsServiceStatus?: number
packageTotalVoice?: number
usedVoice?: number
voiceServiceStatus?: number
roamServiceStatus?: number
statusSyncTime?: string
flowSyncTime?: string
remark?: string
terminalName?: string
isBound?: boolean
canBusiness?: boolean
canAllocate?: boolean
ownerType?: 'public' | 'terminal' | 'agent' | string
}
export interface PublicQueryCardParams {
tenantId?: string
cardValue: string
}
export type CardRecognitionCode
= | 'CARD_RECOGNIZED'
| 'CARD_AMBIGUOUS'
| 'CARD_UNREADABLE'
| 'RECOGNITION_TIMEOUT'
| 'RECOGNITION_FAILED'
| 'RECOGNITION_DISABLED'
| 'RECOGNITION_RATE_LIMITED'
export interface CardRecognitionRes {
code: CardRecognitionCode | string
message: string
cardType: 'iccid' | string
cardValue: string
confidence: number
candidates: string[]
}
export interface CardListParams {
page?: number
pageSize?: number
cardValue?: string
status?: string
categoryCode?: string
includeTotal?: boolean
}
export interface AppPackage {
id: number
subscriptionNo?: string
packageCode?: string
packageName?: string
packageType?: number
packageTypeName?: string
categoryCode?: string
categoryName?: string
iccid?: string
msisdn?: string
cardId?: number
cardValue?: string
cardRemark?: string
terminalName?: string
businessType?: number
businessTypeName?: string
effectiveStatus?: number
status?: string
statusType?: 'danger' | 'primary' | 'success' | 'warning' | string
filterValue?: string
effectiveTime?: string
expireTime?: string
validText?: string
effectText?: string
flowMb?: number
flowText?: string
ownerType?: 'public' | 'terminal' | 'agent' | string
canBusiness?: boolean
}
export interface PackageListParams {
page?: number
pageSize?: number
keyword?: string
cardValue?: string
subscriptionNo?: string
packageCode?: string
status?: string
categoryCode?: string
}
export interface PublicPackageDetailParams {
tenantId?: string
cardValue: string
packageCode?: string
subscriptionNo?: string
}
export interface BindCardParams {
tenantId?: string
cardValue: string
remark?: string
}
export interface RestartCardParams {
cardValue: string
remark?: string
}
export interface CardLifecycleParams {
cardValue?: string
cardValues?: string[]
operation: 'stop' | 'start' | 'restart' | string
remark?: string
}
export interface CardLifecycleStatusParams {
batchNo: string
}
export interface RenewPrepareParams {
cardValue: string
packageCode?: string
subscriptionNo?: string
}
export interface RenewBatchPrepareParams {
cardValues: string[]
packageCode?: string
}
export interface RenewSubmitParams {
cardValue: string
packageCode?: string
subscriptionNo?: string
durationMonths?: number
durationYears?: number
buyerNote?: string
}
export interface RenewBatchSubmitParams {
cardValues?: string[]
batchNo?: string
packageCode?: string
durationMonths?: number
durationYears?: number
buyerNote?: string
}
export interface AllocateTargetListParams {
page?: number
pageSize?: number
keyword?: string
}
export interface AllocateTarget {
deptId: number
parentId?: number
deptKey?: string
deptName: string
deptCategory?: string
targetScope?: string
targetScopeName?: string
label?: string
}
export interface AllocateBatchPrepareParams {
cardValues: string[]
targetDeptId: number
}
export interface AllocateBatchSubmitParams {
cardValues?: string[]
batchNo: string
targetDeptId?: number
remark?: string
}
export interface ChangePrepareParams {
cardValues: string[]
newPackageCode?: string
targetPackageCode?: string
}
export interface ChangeSubmitParams {
cardValues?: string[]
batchNo: string
newPackageCode: string
durationMonths: number
buyerNote?: string
remark?: string
}
export interface CardQueryRes {
card: AppCard
}
export interface CardListRes {
items: AppCard[]
total: number
hasMore?: boolean
}
export interface PackageListRes {
items: AppPackage[]
total: number
}
export interface PackageDetailRes {
package: AppPackage
}
export interface RestartCardRes {
batchNo: string
batchType: number
status: string
card: AppCard
}
export interface CardLifecycleRes {
batchNo: string
batchType: number
operation: string
status: string
quantity: number
successCount?: number
failCount?: number
failReason?: string
cards: AppCard[]
}
export interface AllocateTargetListRes {
items: AllocateTarget[]
total: number
}
export interface AllocateBatchRes {
batchNo: string
sourceBatchNo?: string
batchType: number
quantity: number
totalCount?: number
validCount?: number
failCount?: number
failReason?: string
target: AllocateTarget
cards: AppCard[]
details?: Array<{
rowNo: number
itemId: string
status: string
errorReason?: string
}>
status: string
}
export interface ChangePackageOption {
package: AppPackage
options: AppRenewOption[]
unitPrice: number
priceText: string
}
export interface ChangePrepareRes {
batchNo: string
batchType: number
quantity: number
cards: AppCard[]
currentPackage: AppPackage
targetPackages: ChangePackageOption[]
effectiveRule: string
}
export interface ChangeSubmitRes {
batchNo: string
batchType: number
orderId: number
orderSn: string
totalAmount: number
priceText: string
quantity: number
orderStatus: number
payStatus: number
status: string
effectiveRule: string
cards: AppCard[]
currentPackage: AppPackage
newPackage: AppPackage
option: AppRenewOption
}
export interface AppRenewOption {
durationMonths: number
actualDurationMonths?: number
durationYears?: number
durationValue?: number
durationUnit?: string
durationUnitText?: string
label: string
unitPrice: number
totalAmount: number
priceText: string
expireAfter?: string
recommended?: boolean
}
export interface RenewPackageOption {
package: AppPackage
packageCode: string
packageName: string
renewUnitPrice: number
unitPrice?: number
priceSource?: string
priceText?: string
renewableDurationOptions?: number[]
options: AppRenewOption[]
}
export interface RenewPrepareRes {
batchNo?: string
batchType?: number
card: AppCard
cards?: AppCard[]
quantity?: number
currentPackage?: AppPackage
package: AppPackage
renewablePackageCodes?: string[]
renewablePackages?: RenewPackageOption[]
options: AppRenewOption[]
priceSource: string
unitPrice: number
effectiveRule: string
recommendIndex: number
}
export interface RenewSubmitRes {
batchNo?: string
batchType?: number
orderId: number
orderSn: string
totalAmount: number
priceText: string
quantity?: number
orderStatus: number
payStatus: number
status: string
effectiveRule: string
card: AppCard
cards?: AppCard[]
package: AppPackage
option: AppRenewOption
}
+53
View File
@@ -0,0 +1,53 @@
import type { AppEnterpriseProfile, AppTerminalProfile } from './login'
export type AppCustomerType = 'all' | 'enterprise' | 'terminal'
export interface CustomerListParams {
page?: number
pageSize?: number
keyword?: string
customerType?: AppCustomerType
status?: string
}
export interface AppCustomer {
customerType: AppCustomerType | string
customerTypeName: string
customerId: number
deptId?: number
terminalId?: number
parentDeptId?: number
customerCode?: string
customerName: string
deptCategory?: string
phone?: string
email?: string
status?: string
statusText: string
realNameStatus: string
balance?: number
cardCount: number
normalCardCount: number
address?: string
fullName?: string
createdAt?: string
}
export interface AppCustomerDetail extends AppCustomer {
enterpriseProfile?: AppEnterpriseProfile | null
terminalProfile?: AppTerminalProfile | null
}
export interface CustomerListRes {
items: AppCustomer[]
total: number
}
export interface CustomerDetailParams {
customerType: Exclude<AppCustomerType, 'all'> | string
customerId: number
}
export interface CustomerDetailRes {
customer: AppCustomerDetail
}
+102
View File
@@ -0,0 +1,102 @@
import type { AppCard } from './card'
export interface DiagnosisInfoParams {
queryCode: string
}
export interface DiagnosisFlowParams {
queryCode: string
queryType: '7d' | '30d' | '3M' | '6M'
}
export interface DiagnosisInfoItem {
iccid?: string
imsi?: string
msisdn?: string
status?: string
dataStatus?: string
onlineStatus?: string
operatingStatus?: string
deviceStatus?: string
imeiStatus?: string
regionLimitStatus?: string
regionLimitOrderStatus?: string
regionLimitTriggerStatus?: string
regionLimitType?: string
regionLimitArea?: string
humanLimitStatus?: string
rateLimitStatus?: string
rateLimitReason?: string
rateLimitDetail?: string
rateLimitApnDetails?: DiagnosisRateLimitApnItem[]
packageTotalFlowMb?: number
usedFlowMb?: number
remainFlowMb?: number
flowSyncTime?: string
stopReason?: string
frozenStatus?: string
frozenReason?: string
frozenOperTime?: string
}
export interface DiagnosisRateLimitApnItem {
apnName?: string
apnStatus?: string
modifyTime?: string
overUseDetails?: string
apnServiceUsageState?: string
pccDetails?: DiagnosisRateLimitPccItem[]
}
export interface DiagnosisRateLimitPccItem {
pccServiceCode?: string
pccName?: string
pccPolicyStatus?: string
pccServiceUsageState?: string
modifyTime?: string
}
export interface DiagnosisSessionItem {
apnId?: string
rat?: string
imei?: string
createTime?: string
ipv4?: string
ipv6?: string
ipv6Prefix?: string
status?: string
}
export interface DiagnosisPackageItem {
packageName?: string
packageType?: string
effectiveStatus?: string
packageTotalFlowMb?: number
usedFlowMb?: number
remainFlowMb?: number
durationMonths?: number
expectedExpireTime?: string
}
export interface DiagnosisInfoRes {
info?: DiagnosisInfoItem
session?: DiagnosisSessionItem
package?: DiagnosisPackageItem
modules?: Record<string, DiagnosisModuleItem>
card?: AppCard
}
export interface DiagnosisModuleItem {
status: 'success' | 'error' | 'cached' | 'skipped' | string
message?: string
cached?: boolean
updatedAt?: string
}
export interface DiagnosisFlowRes {
times: string[]
flow: number[]
unit: string
granularity?: 'day' | 'month' | string
hasData?: boolean
}
+246
View File
@@ -0,0 +1,246 @@
export type AuthMode = 'single' | 'double'
export interface ISingleTokenRes {
token: string
expiresIn: number
userInfo?: IUserInfoRes
}
export interface IDoubleTokenRes {
accessToken: string
refreshToken: string
accessExpiresIn: number
refreshExpiresIn: number
}
export interface ILoginCandidate {
userId: number
username: string
nickName: string
phone: string
deptId?: number
deptName: string
userType: string
role: string
}
export interface ILoginSelectRes {
needSelect: true
candidates: ILoginCandidate[]
}
export type IAuthLoginRes = ISingleTokenRes | IDoubleTokenRes | ILoginSelectRes
export interface IWechatNeedBindRes {
needBindPhone: true
bindTicket: string
bindExpireIn?: number
needSelect?: boolean
candidates?: ILoginCandidate[]
}
export type IWechatLoginRes = ISingleTokenRes | IDoubleTokenRes | IWechatNeedBindRes
export type IWechatBindPhoneRes = ISingleTokenRes
| IDoubleTokenRes
| (ILoginSelectRes & Pick<IWechatNeedBindRes, 'needBindPhone' | 'bindTicket'>)
export type UserRole = string
export interface AppEnterpriseProfile {
deptId?: number
cardCount?: number
deptName: string
deptKey: string
phone: string
email: string
statusText: string
creditCode: string
areaCode: string
address: string
fullName: string
industry: string
legalPersonName: string
legalPersonIdCard: string
companyType: string
establishTime: string
bankName: string
bankAccount: string
realNameStatus: string
}
export interface AppTerminalProfile {
cardCount?: number
terminalName: string
phone: string
email: string
statusText: string
creditCode: string
areaCode: string
address: string
fullName: string
industry: string
legalPersonName: string
legalPersonIdCard: string
companyType: string
establishTime: string
bankName: string
bankAccount: string
realNameStatus: string
}
export interface ILoginForm {
tenantId: string
account: string
username?: string
password: string
selectedUserId?: number
wxCode?: string
}
export interface IWechatLoginForm {
tenantId: string
wxCode: string
}
export interface IWechatBindPhoneForm {
tenantId: string
bindTicket: string
phone: string
captchaKey?: string
captchaPoint?: string
selectedUserId?: number
}
export interface IRegisterForm {
tenantId: string
phone: string
password: string
nickName: string
/** 短信验证码:当前注册未接入短信,图形验证通过后可为空 */
smsCode?: string
}
export interface IForgotPasswordForm {
tenantId: string
phone: string
password: string
smsCode: string
}
export interface IUserInfoRes {
userId?: number
tenantId?: string
deptId?: number
username: string
nickName: string
nickname?: string
phone: string
avatar?: string
sex?: '0' | '1' | '2' | string
userType?: string
isTerminal?: boolean
canEnterpriseBusiness?: boolean
wechatBound?: boolean
wechatBindText?: string
balance?: number
role?: UserRole
roleName?: string
roleKey?: string
roleNames?: string[]
roles?: UserRole[]
accessCodes?: string[]
enterpriseProfile?: AppEnterpriseProfile | null
terminalProfile?: AppTerminalProfile | null
[key: string]: any
}
export interface AuthStorage {
mode: AuthMode
tokens: ISingleTokenRes | IDoubleTokenRes
userInfo?: IUserInfoRes
loginTime: number
}
export interface ISideCaptcha {
captchaKey: string
imageBase64: string
tileBase64: string
tileWidth: number
tileHeight: number
tileX: number
tileY: number
}
export interface ICheckSideCaptchaForm {
captchaKey: string
point: string
}
export interface ISendSmsForm {
tenantId: string
phone: string
scene: 'register' | 'forgotPassword'
}
export interface ISendSmsRes {
success: boolean
smsCode?: string
}
export interface IUploadSuccessInfo {
fileId: number
originalName: string
fileName: string
storagePath: string
fileHash: string
fileType: string
fileBusinessType: string
fileSize: number
}
export interface IUpdateInfo {
nickName: string
avatar?: string
sex: string
terminalName?: string
phone?: string
email?: string
creditCode?: string
areaCode?: string
address?: string
fullName?: string
industry?: string
legalPersonName?: string
legalPersonIdCard?: string
companyType?: string
establishTime?: string
bankName?: string
bankAccount?: string
}
export interface IWechatBindForm {
wxCode: string
}
export interface IWechatBindRes {
success: boolean
userInfo: IUserInfoRes
}
export interface IUpdatePassword {
oldPassword: string
newPassword: string
confirmPassword: string
}
export function isSingleTokenRes(tokenRes: IAuthLoginRes): tokenRes is ISingleTokenRes {
return 'token' in tokenRes && !('refreshToken' in tokenRes)
}
export function isDoubleTokenRes(tokenRes: IAuthLoginRes): tokenRes is IDoubleTokenRes {
return 'accessToken' in tokenRes && 'refreshToken' in tokenRes
}
export function isLoginSelectRes(tokenRes: IAuthLoginRes): tokenRes is ILoginSelectRes {
return 'needSelect' in tokenRes && tokenRes.needSelect === true
}
+63
View File
@@ -0,0 +1,63 @@
export type MessageGroupKey = 'system' | 'card' | string
export type MessageTagType = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'volcano' | 'lightblue' | 'cyan' | 'pink' | 'purple'
export interface MessageItem {
messageId?: number
group?: MessageGroupKey
source?: 'user_message' | 'card_warn' | string
title: string
summary: string
time: string
unread: boolean
tag?: string
tagType?: MessageTagType
link?: string
cardValue?: string
warnType?: string
severity?: number
}
export interface MessageGroup {
key: MessageGroupKey
label: string
unreadCount: number
searchPlaceholder: string
tag: string
tagType: MessageTagType
list: MessageItem[]
}
export interface WarnSummary {
total: number
critical: number
warning: number
reminder: number
latestTime?: string
items?: MessageItem[]
}
export interface MessageCenterParams {
page?: number
pageSize?: number
group?: MessageGroupKey
keyword?: string
timeRange?: MessageTimeRange
sortBy?: MessageSortBy
}
export type MessageTimeRange = 'all' | '30d'
export type MessageSortBy = 'none' | 'time_desc' | 'time_asc'
export interface MessageCenterRes {
groups: MessageGroup[]
totalUnread: number
warnSummary?: WarnSummary
}
export interface MessageReadParams {
messageIds: number[]
group?: MessageGroupKey
source?: MessageItem['source']
}
+72
View File
@@ -0,0 +1,72 @@
export interface OrderListParams {
page: number
pageSize: number
keyword?: string
orderType?: number
payStatus?: number
}
export interface AppOrderItem {
itemId: number
productName: string
productSn: string
price: number
priceText: string
quantity: number
originPrice: number
originPriceText: string
productType: number
serviceBeginTime?: string
serviceEndTime?: string
}
export interface AppOrder {
orderId: number
orderSn: string
orderType: number
orderTypeText: string
orderStatus: number
orderStatusText: string
payType: number
payTypeText: string
payStatus: number
payStatusText: string
payOrderSn: string
returnStatus: number
returnStatusText: string
paymentExpireAt?: string
paymentRemainingSeconds?: number
paymentExpired?: boolean
totalAmount: number
totalAmountText: string
paidAmount: number
paidAmountText: string
onlinePaidAmount: number
onlinePaidAmountText: string
balance: number
balanceText: string
unpaidAmount: number
unpaidAmountText: string
unrefundAmount: number
unrefundAmountText: string
buyerNote: string
remark: string
createdAt?: string
payTime?: string
itemSummary: string
itemCount: number
items?: AppOrderItem[]
}
export interface OrderListRes {
items: AppOrder[]
total: number
}
export interface OrderDetailParams {
orderSn: string
}
export interface OrderDetailRes {
order: AppOrder
}
+127
View File
@@ -0,0 +1,127 @@
import type { AppCard } from './card'
export interface WarningConfig {
id?: number
itemType?: number
itemId?: number
itemCode?: string
warnType?: number
warnUnit: number
warnTitle?: string
unitText?: string
firstWarn: number
secondWarn?: number
thirdWarn?: number
autoAction?: number
isEnabled: number
appNotifyEnabled?: number
wxTemplateId?: string
remark?: string
updatedAt?: string
}
export interface WarningConfigListParams {
cardValue: string
}
export interface WarningConfigListRes {
card: AppCard
items: WarningConfig[]
templateId: string
templateReady: boolean
wechatBound: boolean
wechatBindText: string
}
export interface WarningConfigSaveParams {
cardValue: string
warnUnit: number
firstWarn: number
secondWarn?: number
thirdWarn?: number
isEnabled: number
appNotifyEnabled: number
remark?: string
}
export interface WarningConfigSaveRes {
id: number
config: WarningConfig
templateId: string
templateReady: boolean
wechatBound: boolean
wechatBindText: string
}
export interface WarningConfigNotifyParams {
cardValue: string
warnUnit?: number
appNotifyEnabled: number
}
export interface WarningConfigNotifyRes {
appNotifyEnabled: number
config?: WarningConfig
templateId: string
templateReady: boolean
wechatBound: boolean
wechatBindText: string
}
export interface WarningConfigMetaRes {
templateId: string
templateReady: boolean
wechatBound: boolean
wechatBindText: string
appNotifyEnabled: number
}
export interface WarningConfigBatchSaveParams {
cardValues: string[]
configs: WarningConfigBatchItemParams[]
}
export interface WarningConfigBatchItemParams {
warnUnit: number
firstWarn: number
secondWarn?: number
thirdWarn?: number
isEnabled: number
appNotifyEnabled: number
remark?: string
}
export interface WarningConfigBatchFailItem {
cardValue: string
reason: string
}
export interface WarningConfigBatchSaveRes {
batchNo: string
totalCount: number
successCount: number
failCount: number
failItems: WarningConfigBatchFailItem[]
templateId: string
templateReady: boolean
wechatBound: boolean
wechatBindText: string
}
export interface WarningConfigBatchNotifyParams {
cardValues: string[]
warnUnit?: number
appNotifyEnabled: number
}
export interface WarningConfigBatchNotifyRes {
appNotifyEnabled: number
totalCount: number
successCount: number
failCount: number
failItems: WarningConfigBatchFailItem[]
templateId: string
templateReady: boolean
wechatBound: boolean
wechatBindText: string
}
+62
View File
@@ -0,0 +1,62 @@
export interface WorkOrderListParams {
page: number
pageSize: number
keyword?: string
currentStatus?: number
woType?: number
}
export interface WorkOrderDetailParams {
woNo: string
}
export interface WorkOrderCreateParams {
woType: number
relatedId?: number
title: string
content: string
attachmentUrls?: string[]
}
export interface AppWorkOrderCommunication {
id: number
woNo: string
commType: number
commTypeText: string
senderName: string
content: string
attachmentUrls?: string[]
createdAt?: string
}
export interface AppWorkOrder {
id: number
woNo: string
woType: number
woTypeText: string
relatedId: number
title: string
content: string
creatorType: number
creatorName: string
creatorPhone: string
currentStatus: number
currentStatusText: string
statusType: string
createdAt?: string
updatedAt?: string
communications?: AppWorkOrderCommunication[]
}
export interface WorkOrderListRes {
items: AppWorkOrder[]
total: number
}
export interface WorkOrderDetailRes {
workOrder: AppWorkOrder
}
export interface WorkOrderCreateRes {
workOrder: AppWorkOrder
}
+81
View File
@@ -0,0 +1,81 @@
import type {
WarningConfigBatchNotifyParams,
WarningConfigBatchNotifyRes,
WarningConfigBatchSaveParams,
WarningConfigBatchSaveRes,
WarningConfigListParams,
WarningConfigListRes,
WarningConfigMetaRes,
WarningConfigNotifyParams,
WarningConfigNotifyRes,
WarningConfigSaveParams,
WarningConfigSaveRes,
} from './types/warning'
import { http } from '@/http/http'
export type {
WarningConfig,
WarningConfigBatchItemParams,
WarningConfigBatchNotifyParams,
WarningConfigBatchNotifyRes,
WarningConfigBatchSaveParams,
WarningConfigBatchSaveRes,
WarningConfigListRes,
WarningConfigNotifyParams,
WarningConfigNotifyRes,
WarningConfigSaveParams,
} from './types/warning'
export function getWarningConfigList(params: WarningConfigListParams) {
return http.get<WarningConfigListRes>('/warning/config/list', params, undefined, {
hideErrorToast: true,
})
}
export function saveWarningConfig(data: WarningConfigSaveParams) {
return http.post<WarningConfigSaveRes>('/warning/config/save', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function updateWarningConfigNotify(data: WarningConfigNotifyParams) {
return http.post<WarningConfigNotifyRes>(
'/warning/config/notify',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function getWarningConfigMeta() {
return http.get<WarningConfigMetaRes>('/warning/config/meta', undefined, undefined, {
hideErrorToast: true,
})
}
export function saveWarningConfigBatch(data: WarningConfigBatchSaveParams) {
return http.post<WarningConfigBatchSaveRes>(
'/warning/config/batch-save',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function updateWarningConfigNotifyBatch(data: WarningConfigBatchNotifyParams) {
return http.post<WarningConfigBatchNotifyRes>(
'/warning/config/batch-notify',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
+35
View File
@@ -0,0 +1,35 @@
import type {
WorkOrderCreateParams,
WorkOrderCreateRes,
WorkOrderDetailParams,
WorkOrderDetailRes,
WorkOrderListParams,
WorkOrderListRes,
} from './types/workOrder'
import { http } from '@/http/http'
export type {
AppWorkOrder,
AppWorkOrderCommunication,
WorkOrderCreateParams,
WorkOrderDetailParams,
WorkOrderListParams,
} from './types/workOrder'
export function getWorkOrderList(params: WorkOrderListParams) {
return http.get<WorkOrderListRes>('/work-order/list', params, undefined, {
hideErrorToast: true,
})
}
export function getWorkOrderDetail(params: WorkOrderDetailParams) {
return http.get<WorkOrderDetailRes>('/work-order/detail', params, undefined, {
hideErrorToast: true,
})
}
export function createWorkOrder(data: WorkOrderCreateParams) {
return http.post<WorkOrderCreateRes>('/work-order/create', data, undefined, undefined, {
hideErrorToast: true,
})
}
View File
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { getCurrentPageI18nKey, isCurrentPageTabbar } from '@/utils'
defineProps<{
desc?: string
}>()
/** ==================== 数据 ==================== */
const title = ref('')
const isTabbarPage = ref(true)
/** ==================== 方法 ==================== */
function handleClickLeft() {
uni.navigateBack()
}
/** ==================== 周期 ==================== */
onMounted(() => {
title.value = getCurrentPageI18nKey()
isTabbarPage.value = isCurrentPageTabbar()
})
</script>
<template>
<wd-navbar
:fixed="true"
safe-area-inset-top
placeholder
custom-class="navbar"
>
<template #left>
<view
v-if="!isTabbarPage"
class="nav-back"
@click="handleClickLeft"
>
<wd-icon name="left" size="16px" custom-class="nav-back-ico" />
</view>
</template>
<template #title>
<view class="nav-title-row">
<view class="nav-title">{{ title }}</view>
</view>
</template>
</wd-navbar>
</template>
<style lang="scss" scoped>
:deep(.navbar) {
box-sizing: border-box;
padding: 0 20rpx;
background: #fff;
border-bottom: 1px solid #e2e8f0;
box-shadow: none;
.wd-navbar__content {
box-sizing: border-box;
padding-top: 4rpx !important;
display: flex;
flex-direction: row;
align-items: center;
background: #fff !important;
}
.wd-navbar__left {
padding: 0;
position: static;
}
.wd-navbar__title {
max-width: 100%;
width: 100%;
text-align: center;
}
.wd-navbar__right {
padding: 0;
}
}
.nav-back {
height: 52rpx;
width: 52rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10rpx;
background: #eff6ff;
}
:deep(.nav-back-ico) {
color: #2563eb !important;
}
.nav-title-row {
display: flex;
height: 64rpx;
align-items: center;
justify-content: center;
}
.nav-title {
max-width: 360rpx;
overflow: hidden;
color: #0f172a;
font-size: 17px;
font-weight: 700;
line-height: 32px;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script setup lang="ts">
import GoCaptchaUni from 'go-captcha-uni'
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from 'vue'
import { getSideCaptcha } from '@/api/login'
defineOptions({ name: 'SideCaptcha' })
const emit = defineEmits<{
confirm: [{ captchaKey: string, posX: number, posY: number }]
}>()
const instance = getCurrentInstance()
const DEFAULT_WIDTH = 300
const DEFAULT_HEIGHT = 220
const loading = ref(false)
const errorMessage = ref('')
const captchaKey = ref('')
const scaleRatio = ref(1)
const captchaRef = ref<InstanceType<typeof GoCaptchaUni> | null>(null)
let requestSerial = 0
const captchaConfig = reactive({
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
showTheme: false,
verticalPadding: 0,
horizontalPadding: 0,
})
const captchaData = reactive({
thumbX: 0,
thumbY: 0,
thumbWidth: 0,
thumbHeight: 0,
image: '',
thumb: '',
})
const captchaReady = computed(() =>
!loading.value
&& !errorMessage.value
&& !!captchaKey.value
&& !!captchaData.image
&& !!captchaData.thumb
&& captchaData.thumbWidth > 0
&& captchaData.thumbHeight > 0,
)
function toBase64(src?: string) {
return src
? (src.startsWith('data:image') ? src : `data:image/png;base64,${src}`)
: ''
}
function stripBase64Prefix(src: string) {
return src.replace(/^data:image\/\w+;base64,/, '')
}
function normalizeCaptchaImage(src: string, name: string) {
return new Promise<string>((resolve, reject) => {
const dataUri = toBase64(src)
if (!dataUri) {
reject(new Error('验证码图片为空'))
return
}
// #ifdef MP-WEIXIN
const userDataPath = wx.env?.USER_DATA_PATH
if (!userDataPath) {
reject(new Error('小程序本地图片目录不可用'))
return
}
const filePath = `${userDataPath}/${name}-${Date.now()}-${Math.random().toString(36).slice(2)}.png`
wx.getFileSystemManager().writeFile({
filePath,
data: stripBase64Prefix(dataUri),
encoding: 'base64',
success: () => resolve(filePath),
fail: error => reject(new Error(error.errMsg || '验证码图片写入失败')),
})
// #endif
// #ifndef MP-WEIXIN
resolve(dataUri)
// #endif
})
}
function measureContainerWidth(): Promise<number> {
return new Promise((resolve) => {
if (!instance) {
resolve(DEFAULT_WIDTH)
return
}
uni
.createSelectorQuery()
.in(instance)
.select('.side-captcha-host')
.boundingClientRect((rect) => {
resolve(!Array.isArray(rect) && rect?.width ? Math.floor(rect.width) : DEFAULT_WIDTH)
})
.exec()
})
}
function getImageSize(src: string) {
return new Promise<{ width: number, height: number }>((resolve) => {
uni.getImageInfo({
src,
success: ({ width, height }) => resolve({ width, height }),
fail: () => resolve({ width: 0, height: 0 }),
})
})
}
async function fetchCaptcha() {
const currentRequest = ++requestSerial
loading.value = true
errorMessage.value = ''
captchaKey.value = ''
Object.assign(captchaData, {
thumbX: 0,
thumbY: 0,
thumbWidth: 0,
thumbHeight: 0,
image: '',
thumb: '',
})
try {
const res = await getSideCaptcha()
const imageSrc = await normalizeCaptchaImage(res.imageBase64, 'side-captcha-bg')
const thumbSrc = await normalizeCaptchaImage(res.tileBase64, 'side-captcha-tile')
if (currentRequest !== requestSerial)
return
const captchaKeyValue = String(res.captchaKey || '').trim()
const tileWidth = Number(res.tileWidth || 0)
const tileHeight = Number(res.tileHeight || 0)
if (!captchaKeyValue || !imageSrc || !thumbSrc || tileWidth <= 0 || tileHeight <= 0)
throw new Error('验证码返回数据不完整')
await nextTick()
const containerW = await measureContainerWidth()
const { width: srcW, height: srcH } = await getImageSize(imageSrc)
const sourceWidth = srcW || 280
const sourceHeight = srcH || 170
const availableWidth = containerW > 0 ? containerW : DEFAULT_WIDTH
const displayWidth = Math.max(240, Math.min(availableWidth, sourceWidth))
const ratio = displayWidth / sourceWidth
scaleRatio.value = ratio
captchaConfig.width = displayWidth
captchaConfig.height = Math.round(sourceHeight * ratio) || DEFAULT_HEIGHT
captchaKey.value = captchaKeyValue
captchaData.image = imageSrc
captchaData.thumb = thumbSrc
captchaData.thumbX = Math.round((res.tileX || 0) * ratio)
captchaData.thumbY = Math.round((res.tileY || 0) * ratio)
captchaData.thumbWidth = Math.round(tileWidth * ratio)
captchaData.thumbHeight = Math.round(tileHeight * ratio)
}
catch (error) {
if (currentRequest !== requestSerial)
return
console.error('获取滑块验证码失败:', error)
errorMessage.value = '验证码加载失败,请点击重新获取'
}
finally {
if (currentRequest === requestSerial)
loading.value = false
}
}
function onCaptchaConfirm(point: { x: number, y: number }) {
if (!captchaReady.value)
return
const posX = Math.round(point.x / (scaleRatio.value || 1))
const posY = Math.round(point.y / (scaleRatio.value || 1))
if (!Number.isFinite(posX) || !Number.isFinite(posY) || posX < 0 || posY < 0) {
return
}
emit('confirm', { captchaKey: captchaKey.value, posX, posY })
}
async function refresh() {
captchaRef.value?.clear()
await nextTick()
await fetchCaptcha()
}
defineExpose({ refresh })
onMounted(fetchCaptcha)
</script>
<template>
<view class="side-captcha-host w-full flex justify-center">
<view v-if="loading" class="min-h-[260px] flex items-center justify-center">
<wd-loading />
</view>
<go-captcha-uni
v-else-if="captchaReady"
ref="captchaRef"
type="slide"
class="captcha"
:config="captchaConfig"
:data="captchaData"
@event-confirm="onCaptchaConfirm"
/>
<view v-else class="captcha-error">
<view class="i-carbon-warning-alt captcha-error__icon" />
<text class="captcha-error__title">验证码暂时无法显示</text>
<text class="captcha-error__desc">{{ errorMessage || '请重新获取验证码' }}</text>
<wd-button size="small" type="primary" @click="refresh">
重新获取
</wd-button>
</view>
</view>
</template>
<style scoped lang="scss">
:deep(.captcha) {
.gc-header {
display: none;
}
}
.captcha-error {
box-sizing: border-box;
width: 100%;
min-height: 260px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12rpx;
padding: 28rpx;
border: 1px solid #e2e8f0;
border-radius: 12rpx;
background: #f8fafc;
text-align: center;
}
.captcha-error__icon {
color: #94a3b8;
font-size: 42rpx;
}
.captcha-error__title {
color: #334155;
font-size: 25rpx;
font-weight: 700;
}
.captcha-error__desc {
max-width: 90%;
color: #64748b;
font-size: 22rpx;
line-height: 32rpx;
}
</style>
+81
View File
@@ -0,0 +1,81 @@
/*******************************
* 此文件由脚本自动生成,请勿手动修改 *
*******************************/
/**
* 页面路径常量
*/
const RouterPath = {
/** 山东平台物联卡 */
Home: '/pages/home/index',
/** 办理 */
Business: '/pages/business/index',
/** 卡片 */
Card: '/pages/card/index',
/** 山东平台物联卡 */
Login: '/pages/login/index',
/** 消息 */
Message: '/pages/message/index',
/** 我的 */
User: '/pages/user/index',
/** 找回密码 */
'LoginForgot-password': '/pages/login/forgot-password/index',
/** 注册账号 */
LoginRegister: '/pages/login/register/index',
/** 加油包 */
BusinessViewsAddon: '/pages/business/views/addon/index',
/** 批量卡片划拨 */
BusinessViewsAllocate: '/pages/business/views/allocate/index',
/** 余额充值 */
BusinessViewsBalanceRecharge: '/pages/business/views/balanceRecharge/index',
/** 单卡套餐续费 */
BusinessViewsCardRenew: '/pages/business/views/cardRenew/index',
/** 套餐变更 */
BusinessViewsChange: '/pages/business/views/change/index',
/** 客户管理 */
BusinessViewsCustomer: '/pages/business/views/customer/index',
/** 客户详情 */
BusinessViewsCustomerDetail: '/pages/business/views/customerDetail/index',
/** 卡片排查 */
BusinessViewsDiagnosis: '/pages/business/views/diagnosis/index',
/** 我的订单 */
BusinessViewsOrder: '/pages/business/views/order/index',
/** 批量套餐续费 */
BusinessViewsRenew: '/pages/business/views/renew/index',
/** 预警配置 */
BusinessViewsWarning: '/pages/business/views/warning/index',
/** 工单列表 */
BusinessViewsWorkOrder: '/pages/business/views/workOrder/index',
/** 提交工单 */
BusinessViewsWorkOrderCreate: '/pages/business/views/workOrderCreate/index',
/** 工单详情 */
BusinessViewsWorkOrderDetail: '/pages/business/views/workOrderDetail/index',
/** 卡片详情 */
CardViewsDetail: '/pages/card/views/detail/index',
/** 全部设备 */
CardViewsManagement: '/pages/card/views/management/index',
/** 卡片详情 */
CardViewsPackageDetail: '/pages/card/views/packageDetail/index',
/** 预警配置 */
CardViewsWarning: '/pages/card/views/warning/index',
/** 消息详情 */
MessageViewsCardDetail: '/pages/message/views/cardDetail/index',
/** 消息详情 */
MessageViewsSystemDetail: '/pages/message/views/systemDetail/index',
/** 关于我们 */
UserViewsAbout: '/pages/user/views/about/index',
/** 协议与隐私 */
UserViewsAgreement: '/pages/user/views/agreement/index',
/** 联系客服 */
UserViewsContact: '/pages/user/views/contact/index',
/** 帮助与反馈 */
UserViewsFeedback: '/pages/user/views/feedback/index',
/** 微信提醒 */
UserViewsNotification: '/pages/user/views/notification/index',
/** 账号资料 */
UserViewsProfile: '/pages/user/views/profile/index',
/** 实名信息 */
UserViewsRealName: '/pages/user/views/realName/index',
}
export default RouterPath
+39
View File
@@ -0,0 +1,39 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '@qiun/ucharts' {
export default class UCharts {
constructor(options: Record<string, any>)
updateData(options?: Record<string, any>): void
showToolTip(event: unknown, options?: Record<string, any>): void
}
}
interface ImportMetaEnv {
/** 网站标题,应用名称 */
readonly VITE_APP_TITLE: string
/** 服务端口号 */
readonly VITE_APP_PORT: string
/** 后台接口地址 */
readonly VITE_SERVER_BASEURL: string
/** 微信小程序后台接口地址,不配置则使用 VITE_SERVER_BASEURL */
readonly VITE_MP_SERVER_BASEURL?: string
/** 后端是否有统一前缀 /api */
readonly VITE_SERVER_HAS_API_PREFIX: 'true' | 'false'
/** 认证模式,'single' | 'double' ==> 单token | 双token */
readonly VITE_AUTH_MODE: 'single' | 'double'
/** 是否清除console */
readonly VITE_DELETE_CONSOLE: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+54
View File
@@ -0,0 +1,54 @@
import type { Ref } from 'vue'
import { ref } from 'vue'
interface IUseRequestOptions<T> {
/** 是否立即执行 */
immediate?: boolean
/** 初始化数据 */
initialData?: T
}
interface IUseRequestReturn<T, P = undefined> {
loading: Ref<boolean>
error: Ref<boolean | Error>
data: Ref<T | undefined>
run: (args?: P) => Promise<T | undefined>
}
/**
* useRequest是一个定制化的请求钩子,用于处理异步请求和响应。
* @param func 一个执行异步请求的函数,返回一个包含响应数据的Promise。
* @param options 包含请求选项的对象 {immediate, initialData}。
* @param options.immediate 是否立即执行请求,默认为false。
* @param options.initialData 初始化数据,默认为undefined。
* @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。
*/
export default function useRequest<T, P = undefined>(
func: (args?: P) => Promise<T>,
options: IUseRequestOptions<T> = { immediate: false },
): IUseRequestReturn<T, P> {
const loading = ref(false)
const error = ref(false)
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
const run = async (args?: P) => {
loading.value = true
return func(args)
.then((res) => {
data.value = res
error.value = false
return data.value
})
.catch((err) => {
error.value = err
throw err
})
.finally(() => {
loading.value = false
})
}
if (options.immediate) {
(run as (args: P) => Promise<T | undefined>)({} as P)
}
return { loading, error, data, run }
}
+116
View File
@@ -0,0 +1,116 @@
# 上拉刷新和下拉加载更多
在 unibest 框架中,我们通过组合 `useScroll` Hook 可结合 `scroll-view` 组件来轻松实现上拉刷新和下拉加载更多的功能。
场景一 页面滚动
```
definePage({
style: {
navigationBarTitleText: '上拉刷新和下拉加载更多',
enablePullDownRefresh: true,
onReachBottomDistance: 100,
},
})
```
场景二 局部滚动 结合 `scroll-view`
## 关键文件
- `src/hooks/useScroll.ts`: 提供了核心的滚动逻辑处理 Hook。
- `src/pages-sub/demo/scroll.vue`: 一个具体的实现示例页面。
## `useScroll` Hook
`useScroll` 是一个 Vue Composition API Hook,它封装了处理下拉刷新和上拉加载的通用逻辑。
### 主要功能
- **管理加载状态**: 自动处理 `loading`(加载中)、`finished`(已加载全部)和 `error`(加载失败)等状态。
- **分页逻辑**: 内部维护分页参数(页码 `page` 和每页数量 `pageSize`)。
- **事件处理**: 提供 `onScrollToLower`(滚动到底部)、`onRefresherRefresh`(下拉刷新)等方法,用于在视图层触发。
- **数据合并**: 自动将新加载的数据追加到现有列表 `list` 中。
### 使用方法
```typescript
import { useScroll } from '@/hooks/useScroll'
import { getList } from '@/service/list' // 你的数据请求API
const {
list, // 响应式的数据列表
loading, // 是否加载中
finished, // 是否已全部加载
error, // 是否加载失败
onScrollToLower, // 滚动到底部时触发的事件
onRefresherRefresh, // 下拉刷新时触发的事件
} = useScroll(getList) // 将获取数据的API函数传入
```
## `scroll-view` 组件
`scroll-view` 是 uni-app 提供的可滚动视图区域组件,它提供了一系列属性来支持下拉刷新和上拉加载。
### 关键属性
- `scroll-y`: 允许纵向滚动。
- `refresher-enabled`: 启用下拉刷新。
- `refresher-triggered`: 控制下拉刷新动画的显示与隐藏,通过 `loading` 状态绑定。
- `@scrolltolower`: 滚动到底部时触发的事件,绑定 `onScrollToLower` 方法。
- `@refresherrefresh`: 触发下拉刷新时触发的事件,绑定 `onRefresherRefresh` 方法。
## 示例代码
以下是 `src/pages-sub/demo/scroll.vue` 中的核心代码,展示了如何将 `useScroll` 和 `scroll-view` 结合使用。
```vue
<template>
<view class="scroll-page">
<scroll-view
class="scroll-view"
scroll-y
:refresher-enabled="true"
:refresher-triggered="loading"
@scrolltolower="onScrollToLower"
@refresherrefresh="onRefresherRefresh"
>
<view v-for="item in list" :key="item.id" class="scroll-item">
{{ item.name }}
</view>
<!-- 加载状态提示 -->
<view v-if="loading" class="loading-tip">加载中...</view>
<view v-if="finished" class="finished-tip">没有更多了</view>
<view v-if="error" class="error-tip">加载失败,请重试</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { useScroll } from '@/hooks/useScroll'
import { getList } from '@/service/list'
const { list, loading, finished, error, onScrollToLower, onRefresherRefresh } = useScroll(getList)
</script>
<style scoped>
/* 样式省略 */
.scroll-page, .scroll-view {
height: 100%;
}
</style>
```
## 实现步骤总结
1. **创建API**: 确保你有一个返回分页数据的API请求函数(例如 `getList`),它应该接受页码和页面大小作为参数。
2. **调用 `useScroll`**: 在你的页面脚本中,导入并调用 `useScroll` Hook,将你的API函数作为参数传入。
3. **模板绑定**:
- 使用 `scroll-view` 组件作为滚动容器。
- 将其 `refresher-triggered` 属性绑定到 `useScroll` 返回的 `loading` 状态。
- 将其 `@scrolltolower` 事件绑定到 `onScrollToLower` 方法。
- 将其 `@refresherrefresh` 事件绑定到 `onRefresherRefresh` 方法。
4. **渲染列表**: 使用 `v-for` 指令渲染 `useScroll` 返回的 `list` 数组。
5. **添加加载提示**: 根据 `loading`, `finished`, `error` 状态,在列表底部显示不同的提示信息,提升用户体验。
通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。
+74
View File
@@ -0,0 +1,74 @@
import type { Ref } from 'vue'
import { onMounted, ref } from 'vue'
interface UseScrollOptions<T> {
fetchData: (page: number, pageSize: number) => Promise<T[]>
pageSize?: number
}
interface UseScrollReturn<T> {
list: Ref<T[]>
loading: Ref<boolean>
finished: Ref<boolean>
error: Ref<any>
refresh: () => Promise<void>
loadMore: () => Promise<void>
}
export function useScroll<T>({
fetchData,
pageSize = 10,
}: UseScrollOptions<T>): UseScrollReturn<T> {
const list = ref<T[]>([]) as Ref<T[]>
const loading = ref(false)
const finished = ref(false)
const error = ref<any>(null)
const page = ref(1)
const loadData = async () => {
if (loading.value || finished.value)
return
loading.value = true
error.value = null
try {
const data = await fetchData(page.value, pageSize)
if (data.length < pageSize) {
finished.value = true
}
list.value.push(...data)
page.value++
}
catch (err) {
error.value = err
}
finally {
loading.value = false
}
}
const refresh = async () => {
page.value = 1
finished.value = false
list.value = []
await loadData()
}
const loadMore = async () => {
await loadData()
}
onMounted(() => {
refresh()
})
return {
list,
loading,
finished,
error,
refresh,
loadMore,
}
}
+171
View File
@@ -0,0 +1,171 @@
import { ref } from 'vue'
import { getEnvBaseUrl } from '@/utils/index'
const VITE_UPLOAD_BASEURL = `${getEnvBaseUrl()}/upload`
type TfileType = 'image' | 'file'
type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*'
type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage
interface TOptions<T extends TfileType> {
formData?: Record<string, any>
maxSize?: number
accept?: T extends 'image' ? TImage[] : TFile[]
fileType?: T
success?: (params: any) => void
error?: (err: any) => void
}
export default function useUpload<T extends TfileType>(options: TOptions<T> = {} as TOptions<T>) {
const {
formData = {},
maxSize = 5 * 1024 * 1024,
accept = ['*'],
fileType = 'image',
success,
error: onError,
} = options
const loading = ref(false)
const error = ref<Error | null>(null)
const data = ref<any>(null)
const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => {
if (size > maxSize) {
uni.showToast({
title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
icon: 'none',
})
return
}
// const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase()
// const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension)
// if (!isTypeValid) {
// uni.showToast({
// title: `仅支持 ${accept.join(', ')} 格式的文件`,
// icon: 'none',
// })
// return
// }
loading.value = true
uploadFile({
tempFilePath,
formData,
onSuccess: (res) => {
// 修改这里的解析逻辑,适应不同平台的返回格式
let parsedData = res
try {
// 尝试解析为JSON
const jsonData = JSON.parse(res)
// 检查是否包含data字段
parsedData = jsonData.data || jsonData
}
catch (e) {
// 如果解析失败,使用原始数据
console.log('Response is not JSON, using raw data:', res)
}
data.value = parsedData
// console.log('上传成功', res)
success?.(parsedData)
},
onError: (err) => {
error.value = err
onError?.(err)
},
onComplete: () => {
loading.value = false
},
})
}
const run = () => {
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
// 微信小程序在2023年10月17日之后,使用本API需要配置隐私协议
const chooseFileOptions = {
count: 1,
success: (res: any) => {
console.log('File selected successfully:', res)
// 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]}
// h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]}
// h5的File有以下字段:{name: "girl.jpeg", size: 48976, type: "image/jpeg"}
// App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]}
// App的File有以下字段:{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976}
let tempFilePath = ''
let size = 0
// #ifdef MP-WEIXIN
tempFilePath = res.tempFiles[0].tempFilePath
size = res.tempFiles[0].size
// #endif
// #ifndef MP-WEIXIN
tempFilePath = res.tempFilePaths[0]
size = res.tempFiles[0].size
// #endif
handleFileChoose({ tempFilePath, size })
},
fail: (err: any) => {
console.error('File selection failed:', err)
error.value = err
onError?.(err)
},
}
if (fileType === 'image') {
// #ifdef MP-WEIXIN
uni.chooseMedia({
...chooseFileOptions,
mediaType: ['image'],
})
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage(chooseFileOptions)
// #endif
}
else {
uni.chooseFile({
...chooseFileOptions,
type: 'all',
})
}
}
return { loading, error, data, run }
}
async function uploadFile({
tempFilePath,
formData,
onSuccess,
onError,
onComplete,
}: {
tempFilePath: string
formData: Record<string, any>
onSuccess: (data: any) => void
onError: (err: any) => void
onComplete: () => void
}) {
uni.uploadFile({
url: VITE_UPLOAD_BASEURL,
filePath: tempFilePath,
name: 'file',
formData,
success: (uploadFileRes) => {
try {
const data = uploadFileRes.data
onSuccess(data)
}
catch (err) {
onError(err)
}
},
fail: (err) => {
console.error('Upload failed:', err)
onError(err)
},
complete: onComplete,
})
}
+13
View File
@@ -0,0 +1,13 @@
# 请求库
目前unibest支持3种请求库:
- 菲鸽简单封装的 `简单版本http`,路径(src/http/http.ts),对应的示例在 src/api/foo.ts
- `alova 的 http`,路径(src/http/alova.ts),对应的示例在 src/api/foo-alova.ts
- `vue-query`, 路径(src/http/vue-query.ts), 目前主要用在自动生成接口,详情看(https://unibest.tech/base/17-generate),示例在 src/service/app 文件夹
## 如何选择
如果您以前用过 alova 或者 vue-query,可以优先使用您熟悉的。
如果您的项目简单,简单版本的http 就够了,也不会增加包体积。(发版的时候可以去掉alova和vue-query,如果没有超过包体积,留着也无所谓 ^_^)
## roadmap
菲鸽最近在优化脚手架,后续可以选择是否使用第三方的请求库,以及选择什么请求库。还在开发中,大概月底出来(8月31号)。
+122
View File
@@ -0,0 +1,122 @@
import type { uniappRequestAdapter } from '@alova/adapter-uniapp'
import type { IResponse } from './types'
import AdapterUniapp from '@alova/adapter-uniapp'
import { createAlova } from 'alova'
import { createServerTokenAuthentication } from 'alova/client'
import VueHook from 'alova/vue'
import { toLoginPage } from '@/utils/toLoginPage'
import { getEnvBaseUrl } from '@/utils'
import { ContentTypeEnum, getHttpErrorMessage, REQUEST_ERROR_MESSAGE, ResultEnum, ShowMessage } from './tools/enum'
// 配置动态Tag
export const API_DOMAINS = {
DEFAULT: getEnvBaseUrl(),
}
/**
* 创建请求实例
*/
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
typeof VueHook,
typeof uniappRequestAdapter
>({
// 如果下面拦截不到,请使用 refreshTokenOnSuccess by 群友@琛
refreshTokenOnError: {
isExpired: (error) => {
return error.response?.status === ResultEnum.Unauthorized
},
handler: async () => {
try {
// await authLogin();
}
catch (error) {
// 切换到登录页
toLoginPage({ mode: 'reLaunch' })
throw error
}
},
},
})
/**
* alova 请求实例
*/
const alovaInstance = createAlova({
baseURL: API_DOMAINS.DEFAULT,
...AdapterUniapp(),
timeout: 5000,
statesHook: VueHook,
beforeRequest: onAuthRequired((method) => {
// 设置默认 Content-Type
method.config.headers = {
ContentType: ContentTypeEnum.JSON,
Accept: 'application/json, text/plain, */*',
...method.config.headers,
}
const { config } = method
const ignoreAuth = !config.meta?.ignoreAuth
console.log('ignoreAuth===>', ignoreAuth)
// 处理认证信息 自行处理认证问题
if (ignoreAuth) {
const token = 'getToken()'
if (!token) {
throw new Error('[请求错误]:未登录')
}
// method.config.headers.token = token;
}
// 处理动态域名
if (config.meta?.domain) {
method.baseURL = config.meta.domain
console.log('当前域名', method.baseURL)
}
}),
responded: onResponseRefreshToken((response, method) => {
const { config } = method
const { requestType } = config
const {
statusCode,
data: rawData,
errMsg,
} = response as UniNamespace.RequestSuccessCallbackResult
// 处理特殊请求类型(上传/下载)
if (requestType === 'upload' || requestType === 'download') {
return response
}
// 处理 HTTP 状态码错误
if (statusCode !== 200) {
const errorMessage = ShowMessage(statusCode) || REQUEST_ERROR_MESSAGE
console.error('errorMessage===>', errorMessage)
uni.showToast({
title: errorMessage,
icon: 'none',
})
console.error('[ALOVA REQUEST ERROR]', { statusCode, errMsg, url: method.url })
throw new Error(errorMessage)
}
// 处理业务逻辑错误
const { code, message, data } = rawData as IResponse
// 0和200当做成功都很普遍,这里直接兼容两者,见 ResultEnum
if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
const errorMessage = getHttpErrorMessage(rawData, REQUEST_ERROR_MESSAGE)
if (config.meta?.toast !== false) {
uni.showToast({
title: errorMessage,
icon: 'none',
})
}
console.error('[ALOVA BUSINESS ERROR]', { code, message, url: method.url })
throw new Error(errorMessage)
}
// 处理成功响应,返回业务数据
return data
}),
})
export const http = alovaInstance
+7
View File
@@ -0,0 +1,7 @@
export {
getHttpErrorMessage,
NETWORK_ERROR_MESSAGE,
normalizeErrorText,
REQUEST_ERROR_MESSAGE,
toUserRequestError,
} from './tools/enum'
+227
View File
@@ -0,0 +1,227 @@
import type { IDoubleTokenRes } from '@/api/types/login'
import type { CustomRequestOptions, IResponse } from '@/http/types'
import { nextTick } from 'vue'
import { useTokenStore } from '@/store/token'
import { isDoubleTokenMode } from '@/utils'
import { toLoginPage } from '@/utils/toLoginPage'
import { getHttpErrorMessage, NETWORK_ERROR_MESSAGE, REQUEST_ERROR_MESSAGE, toUserRequestError } from './error'
import { ResultEnum } from './tools/enum'
let refreshing = false
let taskQueue: (() => void)[] = []
const DEFAULT_REQUEST_TIMEOUT = 30000
const REQUEST_TIMEOUT_BUFFER = 1000
function resolveRequestTimeout(timeout?: number) {
const value = Number(timeout)
return Number.isFinite(value) && value > 0 ? value : DEFAULT_REQUEST_TIMEOUT
}
function showErrorToast(options: CustomRequestOptions, message: string) {
if (options.hideErrorToast)
return
uni.showToast({
icon: 'none',
title: message,
})
}
function showRequestFailDebug(options: CustomRequestOptions, err: UniApp.GeneralCallbackResult) {
console.error('[APP REQUEST FAIL]', {
url: options.url,
method: options.method,
errMsg: err.errMsg,
err,
})
}
export function http<T>(options: CustomRequestOptions) {
return new Promise<T>((resolve, reject) => {
let settled = false
let requestTask: UniApp.RequestTask | undefined
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
const timeout = resolveRequestTimeout(options.timeout)
function clearRequestTimeout() {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = undefined
}
}
function finishResolve(value: T | PromiseLike<T>) {
if (settled)
return
settled = true
clearRequestTimeout()
resolve(value)
}
function finishReject(error: unknown) {
if (settled)
return
settled = true
clearRequestTimeout()
reject(error)
}
const requestOptions: CustomRequestOptions = {
...options,
timeout,
dataType: 'json',
// #ifndef MP-WEIXIN
responseType: 'json',
// #endif
success: async (res) => {
if (settled)
return
try {
const responseData = res.data as IResponse<T>
const code = Number((responseData as any)?.code)
const isTokenExpired = res.statusCode === 401 || code === 401
if (isTokenExpired) {
const tokenStore = useTokenStore()
if (!isDoubleTokenMode) {
await tokenStore.logout()
toLoginPage()
return finishReject(responseData || res)
}
const { refreshToken } = (tokenStore.tokenInfo as IDoubleTokenRes) || {}
if (refreshToken) {
taskQueue.push(() => {
finishResolve(http<T>(options))
})
}
if (refreshToken && !refreshing) {
refreshing = true
try {
await tokenStore.refreshToken()
refreshing = false
nextTick(() => {
uni.hideToast()
})
taskQueue.forEach(task => task())
}
catch (refreshErr) {
console.error('刷新 token 失败:', refreshErr)
refreshing = false
showErrorToast(options, '登录已过期,请重新登录')
await tokenStore.logout()
setTimeout(() => {
toLoginPage()
}, 1200)
}
finally {
taskQueue = []
}
}
return finishReject(responseData || res)
}
if (res.statusCode >= 200 && res.statusCode < 300) {
if (code !== ResultEnum.Success0 && code !== ResultEnum.Success200) {
const message = getHttpErrorMessage(responseData, REQUEST_ERROR_MESSAGE)
showErrorToast(options, message)
return finishReject({ ...responseData, msg: message })
}
return finishResolve(responseData.data)
}
const message = getHttpErrorMessage(res.data, REQUEST_ERROR_MESSAGE, res.statusCode)
showErrorToast(options, message)
finishReject({ ...res, msg: message, message, errMsg: message })
}
catch (error) {
const message = getHttpErrorMessage(error, REQUEST_ERROR_MESSAGE)
showErrorToast(options, message)
finishReject(toUserRequestError(error, message))
}
},
fail(err) {
if (settled)
return
showRequestFailDebug(requestOptions, err)
const message = getHttpErrorMessage(err, NETWORK_ERROR_MESSAGE)
showErrorToast(requestOptions, message)
finishReject(toUserRequestError(err, message))
},
}
timeoutTimer = setTimeout(() => {
if (settled)
return
settled = true
clearRequestTimeout()
const error = { errMsg: `request:fail timeout after ${timeout}ms` }
requestTask?.abort?.()
showRequestFailDebug(requestOptions, error)
showErrorToast(requestOptions, NETWORK_ERROR_MESSAGE)
reject(toUserRequestError(error, NETWORK_ERROR_MESSAGE))
}, timeout + REQUEST_TIMEOUT_BUFFER)
try {
requestTask = uni.request(requestOptions) as unknown as UniApp.RequestTask
}
catch (error) {
const message = getHttpErrorMessage(error, NETWORK_ERROR_MESSAGE)
showErrorToast(requestOptions, message)
finishReject(toUserRequestError(error, message))
}
})
}
export function httpGet<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
method: 'GET',
header,
...options,
})
}
export function httpPost<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
data,
method: 'POST',
header,
...options,
})
}
export function httpPut<T>(url: string, data?: Record<string, any>, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
data,
query,
method: 'PUT',
header,
...options,
})
}
export function httpDelete<T>(url: string, query?: Record<string, any>, header?: Record<string, any>, options?: Partial<CustomRequestOptions>) {
return http<T>({
url,
query,
method: 'DELETE',
header,
...options,
})
}
http.get = httpGet
http.post = httpPost
http.put = httpPut
http.delete = httpDelete
http.Get = httpGet
http.Post = httpPost
http.Put = httpPut
http.Delete = httpDelete
+58
View File
@@ -0,0 +1,58 @@
import type { CustomRequestOptions } from '@/http/types'
import { useTokenStore } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { stringifyQuery } from './tools/queryString'
// 请求基准地址
const baseUrl = getEnvBaseUrl()
// 拦截器配置
const httpInterceptor = {
// 拦截前触发
invoke(options: CustomRequestOptions) {
// 如果您使用了alova,则请把下面的代码放开注释
// alova 执行流程:alova beforeRequest --> 本拦截器 --> alova responded
// return options
// 非 alova 请求,正常执行
// 接口请求支持通过 query 参数配置 queryString
if (options.query) {
const queryStr = stringifyQuery(options.query)
if (options.url.includes('?')) {
options.url += `&${queryStr}`
}
else {
options.url += `?${queryStr}`
}
}
// 非 http 开头需拼接地址
if (!options.url.startsWith('http')) {
options.url = baseUrl + options.url
// TIPS: 如果需要对接多个后端服务,也可以在这里处理,拼接成所需要的地址
}
// 1. 请求超时:保留业务请求显式设置的超时时间。
if (!options.timeout || options.timeout <= 0)
options.timeout = 60000
// 2. (可选)添加小程序端请求头标识
options.header = {
...options.header,
}
// 3. 添加 token 请求头标识
const tokenStore = useTokenStore()
const token = tokenStore.updateNowTime().validToken
if (token) {
options.header.Authorization = `Bearer ${token}`
}
return options
},
}
export const requestInterceptor = {
install() {
// 拦截 request 请求
uni.addInterceptor('request', httpInterceptor)
// 拦截 uploadFile 文件上传
uni.addInterceptor('uploadFile', httpInterceptor)
},
}
+129
View File
@@ -0,0 +1,129 @@
export enum ResultEnum {
// 0和200当做成功都很普遍,这里直接兼容两者(PS:0和200通常都不会当做错误码,但是有的接口会返回0,有的接口会返回200)
Success0 = 0, // 成功
Success200 = 200, // 成功
Error = 400, // 错误
Unauthorized = 401, // 未授权
Forbidden = 403, // 禁止访问(原为forbidden)
NotFound = 404, // 未找到(原为notFound)
MethodNotAllowed = 405, // 方法不允许(原为methodNotAllowed)
RequestTimeout = 408, // 请求超时(原为requestTimeout)
InternalServerError = 500, // 服务器错误(原为internalServerError)
NotImplemented = 501, // 未实现(原为notImplemented)
BadGateway = 502, // 网关错误(原为badGateway)
ServiceUnavailable = 503, // 服务不可用(原为serviceUnavailable)
GatewayTimeout = 504, // 网关超时(原为gatewayTimeout)
HttpVersionNotSupported = 505, // HTTP版本不支持(原为httpVersionNotSupported)
}
export enum ContentTypeEnum {
JSON = 'application/json;charset=UTF-8',
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
FORM_DATA = 'multipart/form-data;charset=UTF-8',
}
export const NETWORK_ERROR_MESSAGE = '网络异常,请检查网络后重试'
export const REQUEST_ERROR_MESSAGE = '请求处理失败,请稍后重试'
const networkErrorPattern
= /request:fail|timeout|failed to fetch|networkerror|err_connection|econnrefused|econnreset|econnaborted|enotfound|net::|socket|connect|refused|abort|not in domain list|url not in domain list|ssl|tls|certificate|网络|超时/i
const technicalDetailPattern
= /https?:\/\/|\/api\/|接口[::]|errMsg|statusCode|StatusCode|request:|XMLHttpRequest|TypeError:|Error:/i
/**
* 根据状态码,生成对应的错误信息
* @param {number|string} status 状态码
* @returns {string} 错误信息
*/
export function ShowMessage(status: number | string): string {
let message: string
switch (status) {
case 400:
message = '请求错误(400)'
break
case 401:
message = '未授权,请重新登录(401)'
break
case 403:
message = '拒绝访问(403)'
break
case 404:
message = '请求出错(404)'
break
case 408:
message = '请求超时(408)'
break
case 500:
message = '服务器错误(500)'
break
case 501:
message = '服务未实现(501)'
break
case 502:
message = '网络错误(502)'
break
case 503:
message = '服务不可用(503)'
break
case 504:
message = '网络超时(504)'
break
case 505:
message = 'HTTP版本不受支持(505)'
break
default:
message = `连接出错(${status})!`
}
return `${message},请检查网络或联系管理员!`
}
export function normalizeErrorText(value: unknown, fallback = REQUEST_ERROR_MESSAGE) {
if (typeof value !== 'string') {
return fallback
}
const message = value.trim().replace(/^Error:\s*/i, '')
if (!message) {
return fallback
}
if (/^\d+$/.test(message)) {
return ShowMessage(Number(message))
}
if (networkErrorPattern.test(message)) {
return NETWORK_ERROR_MESSAGE
}
if (technicalDetailPattern.test(message)) {
return fallback
}
return message
}
export function getHttpErrorMessage(data: any, fallback = REQUEST_ERROR_MESSAGE, statusCode?: number) {
if (data instanceof Error) {
return normalizeErrorText(data.message, fallback)
}
const directMessage = data?.msg || data?.message || data?.data?.msg || data?.data?.message
if (directMessage) {
return normalizeErrorText(directMessage, fallback)
}
const rawErrMsg = data?.errMsg
if (rawErrMsg) {
return normalizeErrorText(rawErrMsg, fallback)
}
const code = Number(data?.code || statusCode || data?.statusCode)
if (Number.isFinite(code) && code >= 400) {
return ShowMessage(code)
}
return fallback
}
export function toUserRequestError(error: unknown, message: string) {
return {
rawError: error,
msg: message,
message,
errMsg: message,
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* 将对象序列化为URL查询字符串,用于替代第三方的 qs 库,节省宝贵的体积
* 支持基本类型值和数组,不支持嵌套对象
* @param obj 要序列化的对象
* @returns 序列化后的查询字符串
*/
export function stringifyQuery(obj: Record<string, any>): string {
if (!obj || typeof obj !== 'object' || Array.isArray(obj))
return ''
return Object.entries(obj)
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
// 对键进行编码
const encodedKey = encodeURIComponent(key)
// 处理数组类型
if (Array.isArray(value)) {
return value
.filter(item => item !== undefined && item !== null)
.map(item => `${encodedKey}=${encodeURIComponent(item)}`)
.join('&')
}
// 处理基本类型
return `${encodedKey}=${encodeURIComponent(value)}`
})
.join('&')
}
+44
View File
@@ -0,0 +1,44 @@
/**
* 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数
*/
export type CustomRequestOptions = UniApp.RequestOptions & {
query?: Record<string, any>
/** 出错时是否隐藏错误提示 */
hideErrorToast?: boolean
} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
/** 主要提供给 openapi-ts-request 生成的代码使用 */
export type CustomRequestOptions_ = Omit<CustomRequestOptions, 'url'>
export interface HttpRequestResult<T> {
promise: Promise<T>
requestTask: UniApp.RequestTask
}
// 通用响应格式(兼容 msg + message 字段)
export type IResponse<T = any> = {
code: number
data: T
message: string
[key: string]: any // 允许额外属性
} | {
code: number
data: T
msg: string
[key: string]: any // 允许额外属性
}
// 分页请求参数
export interface PageParams {
page: number
pageSize: number
[key: string]: any
}
// 分页响应数据
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}
+30
View File
@@ -0,0 +1,30 @@
import type { CustomRequestOptions } from '@/http/types'
import { http } from './http'
/*
* openapi-ts-request 工具的 request 跨客户端适配方法
*/
export default function request<T extends { data?: any }>(
url: string,
options: Omit<CustomRequestOptions, 'url'> & {
params?: Record<string, unknown>
headers?: Record<string, unknown>
},
) {
const requestOptions = {
url,
...options,
}
if (options.params) {
requestOptions.query = requestOptions.params
delete requestOptions.params
}
if (options.headers) {
requestOptions.header = options.headers
delete requestOptions.headers
}
return http<T['data']>(requestOptions)
}
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
//
</script>
<template>
<view class="w-screen flex flex-1 flex-col">
<slot />
</view>
</template>
+5
View File
@@ -0,0 +1,5 @@
<template>
<view class="layout-default w-screen flex flex-1 flex-col">
<slot />
</view>
</template>
+12
View File
@@ -0,0 +1,12 @@
# 注意事项
> 文件夹名字必须为 `locale`, 这是 `uniapp` 官方约定的,如果改为别的,标题将不能正常切换多语言(其他内容还是正常)。
>
> `xxx.json` 的 `xxx` 多语言标识必须与 `uniapp` 官方约定的一致,否则也会出现 BUG。
>
> 查看截图 `screenshots/i18n.png`。
## 参考文档
[uniapp 国际化开发指南](https://uniapp.dcloud.net.cn/tutorial/i18n.html)
[uniapp 国际化-注意事项](https://uniapp.dcloud.net.cn/api/ui/locale.html#onlocalechange) 最下面的注意事项
+11
View File
@@ -0,0 +1,11 @@
{
"tabbar.home": "Home",
"tabbar.about": "About",
"tabbar.me": "Me",
"tabbar.i18n": "I18n",
"i18n.title": "En Title",
"alova.title": "Alova Request",
"weight": "{heavy}KG",
"detail": "{0}cm, {1}KG",
"introduction": "I am {name},height:{detail.height},weight:{detail.weight}"
}
+81
View File
@@ -0,0 +1,81 @@
import { createI18n } from 'vue-i18n'
import en from './en.json'
import zhHans from './zh-Hans.json' // 简体中文
const messages = {
en,
'zh-Hans': zhHans, // key 不能乱写,查看uniapp官网
}
const i18n = createI18n({
locale: uni.getLocale(), // 获取已设置的语言,fallback 语言需要再 manifest.config.ts 中设置
messages,
allowComposition: true,
})
console.log(uni.getLocale())
console.log(i18n.global.locale)
/**
* 可以拿到原始的语言模板,非 vue 文件使用这个方法,
* @param { string } key 多语言的key,eg: "app.name"
* @returns {string} 返回原始的多语言模板,eg: "{heavy}KG"
*/
export function getTemplateByKey(key: string) {
if (!key) {
console.error(`[i18n] Function getTemplateByKey(), key param is required`)
return ''
}
const locale = uni.getLocale()
console.log('locale:', locale)
const message = messages[locale] // 拿到某个多语言的所有模板(是一个对象)
if (Object.keys(message).includes(key)) {
return message[key]
}
try {
const keyList = key.split('.')
return keyList.reduce((pre, cur) => {
return pre[cur]
}, message)
}
catch (error) {
console.error(`[i18n] Function getTemplateByKey(), key param ${key} is not existed.`)
return ''
}
}
/**
* formatI18n('我是{name},身高{detail.height},体重{detail.weight}',{name:'张三',detail:{height:178,weight:'75kg'}})
* 暂不支持数组
* @param template 多语言模板字符串,eg: `我是{name}`
* @param {object | undefined} data 需要传递的数据对象,里面的key与多语言字符串对应,eg: `{name:'菲鸽'}`
* @returns
*/
function formatI18n(template: string, data?: any) {
return template.replace(/\{([^}]+)\}/g, (match, key: string) => {
// console.log( match, key) // => { detail.height } detail.height
const arr = key.trim().split('.')
let result = data
while (arr.length) {
const first = arr.shift()
result = result[first]
}
return result
})
}
/**
* t('introduction',{name:'张三',detail:{height:178,weight:'75kg'}})
* => formatI18n('我是{name},身高{detail.height},体重{detail.weight}',{name:'张三',detail:{height:178,weight:'75kg'}})
* 没有key的,可以不传 data;暂不支持数组
* @param template 多语言模板字符串,eg: `我是{name}`
* @param {object | undefined} data 需要传递的数据对象,里面的key与多语言字符串对应,eg: `{name:'菲鸽'}`
* @returns
*/
export function t(key, data?) {
return formatI18n(getTemplateByKey(key), data)
}
export default i18n
+11
View File
@@ -0,0 +1,11 @@
{
"tabbar.home": "首页",
"tabbar.about": "关于",
"tabbar.me": "我的",
"tabbar.i18n": "多语言",
"i18n.title": "中文标题",
"alova.title": "Alova 请求",
"weight": "{heavy}公斤",
"detail": "{0}cm, {1}公斤",
"introduction": "我是 {name},身高:{detail.height},体重:{detail.weight}"
}
+22
View File
@@ -0,0 +1,22 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import { requestInterceptor } from './http/interceptor'
import { routeInterceptor } from './router/interceptor'
import store from './store'
import '@/style/index.scss'
import 'go-captcha-uni/assets/css/gocaptcha.css'
import 'virtual:uno.css'
import i18n from './locale/index'
export function createApp() {
const app = createSSRApp(App)
app.use(store)
app.use(routeInterceptor)
app.use(requestInterceptor)
app.use(i18n)
return {
app,
}
}
+138
View File
@@ -0,0 +1,138 @@
{
"name": "山东平台物联卡",
"appid": "__UNI__YOURAPPID",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"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\"/>"
],
"minSdkVersion": 21,
"targetSdkVersion": 30,
"abiFilters": [
"armeabi-v7a",
"arm64-v8a"
]
},
"ios": {},
"sdkConfigs": {},
"icons": {
"android": {
"hdpi": "static/app/icons/72x72.png",
"xhdpi": "static/app/icons/96x96.png",
"xxhdpi": "static/app/icons/144x144.png",
"xxxhdpi": "static/app/icons/192x192.png"
},
"ios": {
"appstore": "static/app/icons/1024x1024.png",
"ipad": {
"app": "static/app/icons/76x76.png",
"app@2x": "static/app/icons/152x152.png",
"notification": "static/app/icons/20x20.png",
"notification@2x": "static/app/icons/40x40.png",
"proapp@2x": "static/app/icons/167x167.png",
"settings": "static/app/icons/29x29.png",
"settings@2x": "static/app/icons/58x58.png",
"spotlight": "static/app/icons/40x40.png",
"spotlight@2x": "static/app/icons/80x80.png"
},
"iphone": {
"app@2x": "static/app/icons/120x120.png",
"app@3x": "static/app/icons/180x180.png",
"notification@2x": "static/app/icons/40x40.png",
"notification@3x": "static/app/icons/60x60.png",
"settings@2x": "static/app/icons/58x58.png",
"settings@3x": "static/app/icons/87x87.png",
"spotlight@2x": "static/app/icons/80x80.png",
"spotlight@3x": "static/app/icons/120x120.png"
}
}
}
},
"compatible": {
"ignoreVersion": true
}
},
"app-harmony": {
"distribute": {}
},
"mp-harmony": {
"distribute": {}
},
"quickapp": {},
"mp-weixin": {
"appid": "YOUR_WX_APPID",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true
},
"usingComponents": true,
"optimization": {
"subPackages": true
},
"mergeVirtualHostAttributes": true,
"styleIsolation": "shared",
"lazyCodeLoading": "requiredComponents"
},
"mp-alipay": {
"usingComponents": true,
"styleIsolation": "shared",
"optimization": {
"subPackages": true
},
"compileOptions": {
"globalObjectMode": "enable",
"transpile": {
"script": {
"ignore": [
"node_modules/**"
]
}
}
}
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"h5": {
"router": {
"base": "/app/"
}
}
}
+345
View File
@@ -0,0 +1,345 @@
{
"globalStyle": {
"navigationStyle": "default",
"navigationBarTitleText": "山东平台物联卡",
"navigationBarBackgroundColor": "#f8f8f8",
"navigationBarTextStyle": "black",
"backgroundColor": "#FFFFFF"
},
"easycom": {
"autoscan": true,
"custom": {
"^fg-(.*)": "@/components/fg-$1/fg-$1.vue",
"^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)": "z-paging/components/z-paging$1/z-paging$1.vue",
"^wd-(.*)": "@wot-ui/ui/components/wd-$1/wd-$1.vue"
}
},
"pages": [
// #ifdef H5 || MP-WEIXIN
{
"path": "pages/home/index",
"type": "home",
"style": {
"navigationStyle": "default",
"navigationBarTitleText": "山东平台物联卡",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
},
"layout": false
},
// #endif
{
"path": "pages/business/index",
"type": "page",
"style": {
"navigationBarTitleText": "办理"
},
"layout": "default"
},
{
"path": "pages/card/index",
"type": "page",
"style": {
"navigationBarTitleText": "卡片"
},
"layout": "default"
},
{
"path": "pages/login/index",
"type": "page",
"name": "Login",
"style": {
"navigationBarTitleText": "山东平台物联卡",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"disableScroll": true
},
"layout": false
},
{
"path": "pages/message/index",
"type": "page",
"style": {
"navigationBarTitleText": "消息"
},
"layout": "default"
},
{
"path": "pages/user/index",
"type": "page",
"excludeLoginPath": true,
"style": {
"navigationBarTitleText": "我的",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black"
},
"layout": "blank"
},
{
"path": "pages/login/forgot-password/index",
"type": "page",
"name": "LoginForgotPassword",
"style": {
"navigationBarTitleText": "找回密码"
},
"layout": false
},
{
"path": "pages/login/register/index",
"type": "page",
"name": "LoginRegister",
"style": {
"navigationBarTitleText": "注册账号"
},
"layout": false
}
],
"subPackages": [
{
"root": "pages/business/views",
"pages": [
// GENERATED BY UNI-PAGES, PLATFORM: H5 || MP-WEIXIN
{
"path": "addon/index",
"type": "page",
"style": {
"navigationBarTitleText": "加油包"
}
},
{
"path": "allocate/index",
"type": "page",
"style": {
"navigationBarTitleText": "批量卡片划拨"
}
},
{
"path": "balanceRecharge/index",
"type": "page",
"style": {
"navigationBarTitleText": "余额充值"
}
},
{
"path": "cardRenew/index",
"type": "page",
"style": {
"navigationBarTitleText": "单卡套餐续费"
}
},
{
"path": "change/index",
"type": "page",
"style": {
"navigationBarTitleText": "套餐变更"
}
},
{
"path": "customer/index",
"type": "page",
"style": {
"navigationBarTitleText": "客户管理"
}
},
{
"path": "customerDetail/index",
"type": "page",
"style": {
"navigationBarTitleText": "客户详情"
}
},
{
"path": "diagnosis/index",
"type": "page",
"style": {
"navigationBarTitleText": "卡片排查"
}
},
{
"path": "order/index",
"type": "page",
"style": {
"navigationBarTitleText": "我的订单"
}
},
{
"path": "renew/index",
"type": "page",
"style": {
"navigationBarTitleText": "批量套餐续费"
}
},
{
"path": "warning/index",
"type": "page",
"style": {
"navigationBarTitleText": "预警配置"
}
},
{
"path": "workOrder/index",
"type": "page",
"style": {
"navigationBarTitleText": "工单列表"
}
},
{
"path": "workOrderCreate/index",
"type": "page",
"style": {
"navigationBarTitleText": "提交工单"
}
},
{
"path": "workOrderDetail/index",
"type": "page",
"style": {
"navigationBarTitleText": "工单详情"
}
}
]
},
{
"root": "pages/card/views",
"pages": [
// GENERATED BY UNI-PAGES, PLATFORM: H5 || MP-WEIXIN
{
"path": "detail/index",
"type": "page",
"style": {
"navigationBarTitleText": "卡片详情"
}
},
{
"path": "management/index",
"type": "page",
"style": {
"navigationBarTitleText": "全部设备"
},
"layout": "default"
},
{
"path": "packageDetail/index",
"type": "page",
"style": {
"navigationBarTitleText": "卡片详情"
}
},
{
"path": "warning/index",
"type": "page",
"style": {
"navigationBarTitleText": "预警配置"
}
}
]
},
{
"root": "pages/message/views",
"pages": [
// GENERATED BY UNI-PAGES, PLATFORM: H5 || MP-WEIXIN
{
"path": "cardDetail/index",
"type": "page",
"style": {
"navigationBarTitleText": "消息详情"
}
},
{
"path": "systemDetail/index",
"type": "page",
"style": {
"navigationBarTitleText": "消息详情"
}
}
]
},
{
"root": "pages/user/views",
"pages": [
// GENERATED BY UNI-PAGES, PLATFORM: H5 || MP-WEIXIN
{
"path": "about/index",
"type": "page",
"style": {
"navigationBarTitleText": "关于我们"
}
},
{
"path": "agreement/index",
"type": "page",
"style": {
"navigationBarTitleText": "协议与隐私"
}
},
{
"path": "contact/index",
"type": "page",
"style": {
"navigationBarTitleText": "联系客服"
}
},
{
"path": "feedback/index",
"type": "page",
"style": {
"navigationBarTitleText": "帮助与反馈"
}
},
{
"path": "notification/index",
"type": "page",
"style": {
"navigationBarTitleText": "微信提醒"
}
},
{
"path": "profile/index",
"type": "page",
"excludeLoginPath": true,
"style": {
"navigationBarTitleText": "账号资料"
}
},
{
"path": "realName/index",
"type": "page",
"style": {
"navigationBarTitleText": "实名信息"
}
}
]
}
],
"tabBar": {
"custom": true,
"color": "#94a3b8",
"selectedColor": "#2563eb",
"backgroundColor": "#F8F8F8",
"borderStyle": "black",
"height": "50px",
"fontSize": "10px",
"iconWidth": "24px",
"spacing": "3px",
"list": [
// #ifdef H5 || MP-WEIXIN
{
"text": "首页",
"pagePath": "pages/home/index"
},
// #endif
{
"text": "设备",
"pagePath": "pages/card/index"
},
{
"text": "办理",
"pagePath": "pages/business/index"
},
{
"text": "我的",
"pagePath": "pages/user/index"
}
]
}
}
@@ -0,0 +1,39 @@
<script setup lang="ts">
/** ==================== 常量 ==================== */
const emit = defineEmits<{
action: [action: 'addon' | 'renew']
}>()
/** ==================== 数据 ==================== */
/** ==================== 方法 ==================== */
function handleAction(action: 'addon' | 'renew') {
emit('action', action)
}
/** ==================== 组件 ==================== */
/** ==================== 周期 ==================== */
</script>
<template>
<wd-card custom-class="!mx-0 !mb-0" custom-content-class="!p-32rpx">
<view class="flex flex-col gap-24rpx">
<view class="flex flex-col gap-10rpx">
<text class="text-40rpx text-#111827 font-900 leading-48rpx">
常用业务
</text>
<text class="text-26rpx text-#64748b leading-38rpx">
办理套餐续费、加油包订购和卡片自助服务。
</text>
</view>
<view class="grid grid-cols-2 gap-16rpx">
<wd-button type="primary" block @click="handleAction('renew')">
套餐续费
</wd-button>
<wd-button type="primary" plain block @click="handleAction('addon')">
加油包
</wd-button>
</view>
</view>
</wd-card>
</template>
@@ -0,0 +1,216 @@
<script setup lang="ts">
import type { BusinessServiceItem, BusinessServiceSection, BusinessTone } from '../data'
const props = defineProps<{
section: BusinessServiceSection
}>()
const emit = defineEmits<{
action: [item: BusinessServiceItem]
}>()
const toneClass: Record<BusinessTone, string> = {
primary: 'tone-primary',
blue: 'tone-blue',
green: 'tone-green',
orange: 'tone-orange',
red: 'tone-red',
purple: 'tone-purple',
}
function handleClick(item: BusinessServiceItem) {
emit('action', item)
}
function visibleRoleTag(item: BusinessServiceItem) {
if (item.agentOnly)
return '代理商'
if (item.terminalOnly)
return '用户'
if (item.terminalHidden)
return '企业'
return ''
}
</script>
<template>
<view class="biz-section">
<text class="biz-group-title">{{ props.section.title }}</text>
<view class="biz-list">
<view
v-for="item in props.section.items"
:key="item.title"
class="biz-item"
@click="handleClick(item)"
>
<view class="biz-ico" :class="toneClass[item.tone]">
<view :class="[item.icon]" />
</view>
<view class="biz-mid">
<view class="biz-title-row">
<text class="biz-title">{{ item.title }}</text>
<view v-if="visibleRoleTag(item)" class="app-tag app-tag--info biz-role-tag">
{{ visibleRoleTag(item) }}
</view>
</view>
<text class="biz-desc">{{ item.desc }}</text>
</view>
<view class="i-carbon-chevron-right biz-chev" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.biz-section {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.biz-group-title {
padding: 0 4rpx;
color: #64748b;
font-size: 23rpx;
font-weight: 700;
line-height: 32rpx;
}
.biz-list {
box-sizing: border-box;
overflow: hidden;
padding: 6rpx 22rpx;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(17, 24, 39, 0.04);
}
.biz-item {
display: flex;
min-width: 0;
align-items: center;
gap: 18rpx;
padding: 20rpx 0;
border-bottom: 1px solid #e2e8f0;
}
.biz-item:last-child {
border-bottom: 0;
}
.biz-ico {
display: flex;
width: 76rpx;
height: 76rpx;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 18rpx;
}
.biz-ico > view {
font-size: 38rpx;
}
.biz-mid {
min-width: 0;
flex: 1;
}
.biz-title-row {
display: flex;
min-width: 0;
align-items: center;
gap: 10rpx;
}
.biz-title {
min-width: 0;
flex-shrink: 1;
overflow: hidden;
font-size: 26rpx;
font-weight: 700;
color: #111827;
line-height: 34rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.biz-role-tag {
flex-shrink: 0;
max-width: 118rpx;
padding: 2rpx 12rpx;
overflow: hidden;
font-size: 20rpx;
line-height: 28rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
.biz-desc {
display: block;
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 21rpx;
color: #64748b;
line-height: 30rpx;
}
.biz-chev {
color: #cbd5e1;
font-size: 28rpx;
flex-shrink: 0;
}
.tone-primary {
background: #eff6ff;
}
.tone-primary > view {
color: #2563eb;
}
.tone-blue {
background: #e0f2fe;
}
.tone-blue > view {
color: #0369a1;
}
.tone-green {
background: #dcfce7;
}
.tone-green > view {
color: #16a34a;
}
.tone-orange {
background: #fef3c7;
}
.tone-orange > view {
color: #d97706;
}
.tone-red {
background: #fee2e2;
}
.tone-red > view {
color: #dc2626;
}
.tone-purple {
background: #e0e7ff;
}
.tone-purple > view {
color: #4f46e5;
}
</style>
+200
View File
@@ -0,0 +1,200 @@
import fs from 'node:fs'
import { fileURLToPath, URL } from 'node:url'
import { describe, expect, it } from 'vitest'
import { businessSections, getUserBusinessSections } from './data'
const businessDir = fileURLToPath(new URL('.', import.meta.url))
const expectedSubViews = [
'diagnosis',
'renew',
'allocate',
'change',
'addon',
'customer',
'customerDetail',
'order',
'warning',
'workOrder',
'workOrderCreate',
'workOrderDetail',
]
describe('getUserBusinessSections', () => {
it('只保留用户侧一级业务入口', () => {
const sections = getUserBusinessSections(businessSections)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).toContain('套餐续费')
expect(titles).not.toContain('余额充值')
expect(titles).toContain('重启卡')
expect(titles).toContain('停机 / 复机')
expect(titles).toContain('加油包')
expect(titles).toContain('预警配置')
expect(titles).toContain('卡片排查')
expect(titles).toContain('我的卡片')
expect(titles).toContain('我的订单')
expect(titles).toContain('工单服务')
expect(titles).not.toContain('进入商城')
expect(titles).not.toContain('订单记录')
expect(titles).not.toContain('流量池预警(代理商独有)')
})
it('可办理业务都有明确动作', () => {
const sections = getUserBusinessSections(businessSections)
const actionableItems = sections
.flatMap(section => section.items)
.filter(item => item.action)
expect(actionableItems.map(item => [item.title, item.action])).toEqual([
['套餐续费', 'renew'],
['加油包', 'addon'],
['停机 / 复机', 'cardList'],
['重启卡', 'cardList'],
['卡片排查', 'diagnosis'],
['预警配置', 'warning'],
['我的订单', 'order'],
['工单服务', 'workOrder'],
['我的卡片', 'cardList'],
])
})
it('卡片排查完全开放,不受业务权限过滤', () => {
const sections = getUserBusinessSections({
userId: 18,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: [],
features: [],
routes: [],
menus: [],
},
} as any)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).toContain('卡片排查')
expect(titles).toContain('我的订单')
})
it('企业用户显示卡片划拨、套餐变更和客户管理入口', () => {
const enterpriseUserTypes = ['sys_user', '']
enterpriseUserTypes.forEach((userType, index) => {
const sections = getUserBusinessSections({
userId: 10 + index,
deptId: 100,
userType,
} as any)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).toContain('卡片划拨')
expect(titles).toContain('套餐变更')
expect(titles).toContain('客户管理')
expect(titles).toContain('停机 / 复机')
expect(titles).toContain('重启卡')
})
})
it('终端用户不显示卡片划拨、套餐变更和客户管理入口', () => {
const sections = getUserBusinessSections({
userId: 11,
deptId: 147,
userType: 'app_user',
} as any)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).not.toContain('卡片划拨')
expect(titles).not.toContain('套餐变更')
expect(titles).not.toContain('客户管理')
expect(titles).toContain('停机 / 复机')
expect(titles).toContain('重启卡')
expect(titles).toContain('加油包')
expect(titles).toContain('预警配置')
expect(titles).toContain('卡片排查')
expect(titles).toContain('我的卡片')
expect(titles).toContain('工单服务')
})
it('配置权限时只展示已授权业务入口', () => {
const sections = getUserBusinessSections({
userId: 12,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: [],
features: ['card:list', 'customer:list'],
routes: [],
menus: [],
},
} as any)
const titles = sections.flatMap(section => section.items.map(item => item.title))
expect(titles).toContain('我的卡片')
expect(titles).toContain('客户管理')
expect(titles).toContain('卡片排查')
expect(titles).toContain('我的订单')
expect(titles).not.toContain('套餐续费')
expect(titles).not.toContain('卡片划拨')
expect(titles).not.toContain('套餐变更')
})
it('控制台权限标识直接决定可见业务入口', () => {
const sections = getUserBusinessSections({
userId: 13,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: ['cpm:iot:tSimCardInfo:list', 'cpm:agent:tAgentEnterprise:list'],
features: [],
routes: [],
menus: [],
},
} as any)
const actions = sections.flatMap(section => section.items.map(item => item.action))
expect(actions).toContain('cardList')
expect(actions).toContain('customer')
expect(actions).not.toContain('renew')
expect(actions).not.toContain('allocate')
expect(actions).not.toContain('change')
})
it('有工单列表或新增权限都展示工单服务入口', () => {
const listSections = getUserBusinessSections({
userId: 14,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: ['cpm:gen:tWoMain:list'],
features: [],
routes: [],
menus: [],
},
} as any)
const createSections = getUserBusinessSections({
userId: 15,
deptId: 100,
userType: 'sys_user',
appAccess: {
configured: true,
permissions: ['cpm:gen:tWoMain:edit'],
features: [],
routes: [],
menus: [],
},
} as any)
expect(listSections.flatMap(section => section.items.map(item => item.title))).toContain('工单服务')
expect(createSections.flatMap(section => section.items.map(item => item.title))).toContain('工单服务')
})
it('业务子界面容器文件都存在', () => {
expectedSubViews.forEach((viewName) => {
expect(fs.existsSync(`${businessDir}views/${viewName}/index.vue`)).toBe(true)
})
})
})
+214
View File
@@ -0,0 +1,214 @@
import type { IUserInfoRes } from '@/api/types/login'
import type { AppPermissionAction } from '@/utils/permission'
import { hasUserPermission } from '@/utils/permission'
import { getAccountWorkspace, isTerminalUserInfo } from '@/utils/userIdentity'
export type BusinessTone = 'blue' | 'green' | 'orange' | 'primary' | 'purple' | 'red'
export interface BusinessServiceItem {
title: string
desc: string
tag: string
icon: string
tone: BusinessTone
action?:
| 'addon'
| 'allocate'
| 'balanceRecharge'
| 'cardList'
| 'change'
| 'customer'
| 'diagnosis'
| 'order'
| 'renew'
| 'warning'
| 'workOrder'
agentOnly?: boolean
terminalOnly?: boolean
terminalHidden?: boolean
permissionAction?: AppPermissionAction | AppPermissionAction[]
}
export interface BusinessServiceSection {
title: string
desc: string
type: 'grid' | 'list'
items: BusinessServiceItem[]
}
/** ==================== 常量 ==================== */
export const businessSections: BusinessServiceSection[] = [
{
title: '套餐与流量',
desc: '续费、变更与加油包',
type: 'grid',
items: [
{
title: '套餐续费',
desc: '续约当前套餐,避免停机',
tag: '续费',
icon: 'i-carbon-renew',
tone: 'green',
action: 'renew',
permissionAction: 'renew',
},
{
title: '加油包',
desc: '流量加餐,按需购买',
tag: '流量',
icon: 'i-carbon-package',
tone: 'blue',
action: 'addon',
permissionAction: 'addon',
},
{
title: '套餐变更',
desc: '更换套餐档位',
tag: '变更',
icon: 'i-carbon-change-catalog',
tone: 'primary',
action: 'change',
agentOnly: true,
permissionAction: 'change',
},
],
},
{
title: '生命周期操作',
desc: '停复机与联网操作',
type: 'grid',
items: [
{
title: '停机 / 复机',
desc: '进入卡片选择暂停或恢复服务',
tag: '生命周期',
icon: 'i-carbon-pause-outline',
tone: 'orange',
action: 'cardList',
permissionAction: ['stop', 'start'],
},
{
title: '重启卡',
desc: '重新建立网络会话',
tag: '重启',
icon: 'i-carbon-restart',
tone: 'primary',
action: 'cardList',
permissionAction: 'restart',
},
],
},
{
title: '管理与预警',
desc: '状态排查、预警与分配',
type: 'grid',
items: [
{
title: '卡片排查',
desc: '状态、会话和流量异常',
tag: '排查',
icon: 'i-carbon-stethoscope',
tone: 'green',
action: 'diagnosis',
},
{
title: '预警配置',
desc: '流量阈值与微信订阅',
tag: '预警',
icon: 'i-carbon-warning-alt',
tone: 'orange',
action: 'warning',
permissionAction: 'warning',
},
{
title: '卡片划拨',
desc: '分配给下游客户/子账号',
tag: '划拨',
icon: 'i-carbon-share-knowledge',
tone: 'blue',
action: 'allocate',
agentOnly: true,
permissionAction: 'allocate',
},
],
},
{
title: '服务记录',
desc: '订单与工单',
type: 'grid',
items: [
{
title: '我的订单',
desc: '续费/加油包办理记录',
tag: '订单',
icon: 'i-carbon-receipt',
tone: 'blue',
action: 'order',
},
{
title: '工单服务',
desc: '反馈问题和查看进度',
tag: '工单',
icon: 'i-carbon-chat',
tone: 'orange',
action: 'workOrder',
permissionAction: ['workOrder', 'workOrderCreate'],
},
{
title: '我的卡片',
desc: '查看卡信息、套餐和流量',
tag: '进入',
icon: 'i-carbon-sim-card',
tone: 'primary',
action: 'cardList',
},
{
title: '客户管理',
desc: '直属客户与客户详情',
tag: '客户',
icon: 'i-carbon-user-multiple',
tone: 'green',
action: 'customer',
agentOnly: true,
permissionAction: 'customer',
},
],
},
]
/** ==================== 方法 ==================== */
export function getUserBusinessSections(
userInfoOrSections?: IUserInfoRes | BusinessServiceSection[],
sections: BusinessServiceSection[] = businessSections,
) {
const userInfo = Array.isArray(userInfoOrSections) ? undefined : userInfoOrSections
const sourceSections = Array.isArray(userInfoOrSections) ? userInfoOrSections : sections
const workspace = getAccountWorkspace(userInfo)
const isAgentUser = Boolean(userInfo) && workspace !== 'terminal'
const isTerminalUser = !userInfo || isTerminalUserInfo(userInfo)
return sourceSections
.map(section => ({
...section,
items: section.items.filter((item) => {
if (item.agentOnly && !isAgentUser)
return false
if (item.terminalOnly && !isTerminalUser)
return false
if (item.terminalHidden && isTerminalUser)
return false
if (userInfo && item.permissionAction && !hasBusinessItemPermission(userInfo, item.permissionAction))
return false
return true
}),
}))
.filter(section => section.items.length > 0)
}
function hasBusinessItemPermission(
userInfo: IUserInfoRes,
action: AppPermissionAction | AppPermissionAction[],
) {
const actions = Array.isArray(action) ? action : [action]
return actions.some(item => hasUserPermission(userInfo, item))
}
+189
View File
@@ -0,0 +1,189 @@
<script setup lang="ts">
import RouterPath from '@/constant/RouterPath'
import type { IUserInfoRes } from '@/api/types/login'
import { useTokenStore } from '@/store/token'
import { useUserStore } from '@/store/user'
import type { AppPermissionAction } from '@/utils/permission'
import { hasUserPermission } from '@/utils/permission'
import BusinessSection from './components/BusinessSection.vue'
import { getUserBusinessSections } from './data'
import type { BusinessServiceItem } from './data'
definePage({
style: {
navigationBarTitleText: '办理',
},
layout: 'default',
})
/** ==================== 常量 ==================== */
const actionRouteMap: Partial<Record<NonNullable<BusinessServiceItem['action']>, string>> = {
renew: RouterPath.BusinessViewsRenew,
allocate: RouterPath.BusinessViewsAllocate,
balanceRecharge: RouterPath.BusinessViewsBalanceRecharge,
change: RouterPath.BusinessViewsChange,
customer: RouterPath.BusinessViewsCustomer,
addon: RouterPath.BusinessViewsAddon,
diagnosis: RouterPath.BusinessViewsDiagnosis,
order: RouterPath.BusinessViewsOrder,
warning: RouterPath.BusinessViewsWarning,
workOrder: RouterPath.BusinessViewsWorkOrder,
}
/** ==================== 数据 ==================== */
const tokenStore = useTokenStore()
const userStore = useUserStore()
let refreshUserPromise: Promise<void | IUserInfoRes> | undefined
const sections = computed(() => getUserBusinessSections(userStore.userInfo))
/** ==================== 方法 ==================== */
async function refreshUserInfoIfNeeded() {
if (!tokenStore.updateNowTime().hasLogin)
return
if (!refreshUserPromise) {
refreshUserPromise = userStore
.fetchUserInfo()
.catch(() => {
// 保留本地已缓存的用户信息,避免网络抖动时业务入口反复隐藏。
})
.finally(() => {
refreshUserPromise = undefined
})
}
await refreshUserPromise
}
async function handleBusinessAction(item: BusinessServiceItem) {
await refreshUserInfoIfNeeded()
if (item.permissionAction && !hasBusinessItemPermission(item.permissionAction)) {
uni.showToast({ title: '暂无操作权限', icon: 'none' })
return
}
if (item.action === 'cardList') {
uni.switchTab({ url: RouterPath.Card })
return
}
const url = item.action ? actionRouteMap[item.action] : ''
if (url) {
uni.navigateTo({ url })
return
}
uni.showToast({
title: item.title,
icon: 'none',
})
}
function goDiagnosis() {
uni.navigateTo({ url: RouterPath.BusinessViewsDiagnosis })
}
function hasBusinessItemPermission(action: AppPermissionAction | AppPermissionAction[]) {
const actions = Array.isArray(action) ? action : [action]
return actions.some(item => hasUserPermission(userStore.userInfo, item))
}
/** ==================== 组件 ==================== */
/** ==================== 周期 ==================== */
onShow(() => {
void refreshUserInfoIfNeeded()
})
</script>
<template>
<view class="business-page">
<view class="business-stack">
<view class="assist-card" @click="goDiagnosis">
<view class="assist-icon">
<view class="i-carbon-stethoscope text-32rpx" />
</view>
<view class="min-w-0 flex-1">
<text class="assist-title">不确定办哪项?先查卡状态</text>
<text class="assist-sub">按卡片状态、套餐、流量和会话信息整理处理方向</text>
<text class="mt-4rpx block text-20rpx text-#2563eb leading-30rpx">
排查建议仅供辅助判断,以实际业务处理结果为准
</text>
</view>
<view class="i-carbon-chevron-right assist-chev" />
</view>
<BusinessSection
v-for="section in sections"
:key="section.title"
:section="section"
@action="handleBusinessAction"
/>
</view>
</view>
</template>
<style lang="scss">
page {
background: #f6f8fb;
}
</style>
<style lang="scss" scoped>
.business-page {
box-sizing: border-box;
min-height: 100vh;
padding: 20rpx 28rpx calc(128rpx + env(safe-area-inset-bottom));
background: #f6f8fb;
}
.business-stack {
display: flex;
max-width: 920rpx;
flex-direction: column;
gap: 18rpx;
margin: 0 auto;
}
.assist-card {
display: flex;
align-items: flex-start;
gap: 18rpx;
padding: 22rpx 24rpx;
border: 1px solid #dbeafe;
border-radius: 12px;
background: linear-gradient(120deg, #eef5ff 0%, #f3f9ff 100%);
}
.assist-icon {
display: flex;
width: 56rpx;
height: 56rpx;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 16rpx;
background: #2563eb;
color: #fff;
}
.assist-title {
display: block;
font-size: 25rpx;
font-weight: 700;
color: #111827;
line-height: 32rpx;
}
.assist-sub {
display: block;
margin-top: 5rpx;
font-size: 21rpx;
color: #64748b;
line-height: 32rpx;
}
.assist-chev {
margin-top: 12rpx;
flex-shrink: 0;
color: #93c5fd;
font-size: 30rpx;
}
</style>
@@ -0,0 +1,219 @@
<script lang="ts" setup>
import { useLastQueriedCardStore, useUserStore } from '@/store'
import { hasAnyFeature } from '@/utils/permission'
import { displayCardValue, displayPackageName, formatDateTime, formatFlowPair, getBossCardStatusText } from '@/pages/card/data'
definePage({
style: {
navigationBarTitleText: '加油包',
},
})
type AddonOption = {
name: string
price: string
desc: string
flowText: string
badge?: string
}
const addonOptions: AddonOption[] = [
{ name: '1GB 加油包', price: '¥8.00', desc: '适合短时补量', flowText: '1GB', badge: '推荐' },
{ name: '3GB 加油包', price: '¥18.00', desc: '日常轻量补充', flowText: '3GB' },
{ name: '5GB 加油包', price: '¥28.00', desc: '中等用量设备', flowText: '5GB' },
{ name: '10GB 加油包', price: '¥48.00', desc: '持续在线设备', flowText: '10GB' },
]
const userStore = useUserStore()
const lastCardStore = useLastQueriedCardStore()
const noPermission = ref(false)
const selectedIndex = ref(0)
const canUseAddon = computed(() => hasAnyFeature(userStore.userInfo, 'card:addon'))
const currentCard = computed(() => lastCardStore.card)
const selectedOption = computed(() => addonOptions[selectedIndex.value] || addonOptions[0])
const selectedAmountText = computed(() => selectedOption.value?.price || '¥0.00')
const cardValueText = computed(() => {
const card = currentCard.value
return card ? displayCardValue(card) : '请选择卡片'
})
const packageText = computed(() => {
const card = currentCard.value
return card ? displayPackageName(card) : '当前套餐'
})
const flowText = computed(() => {
const card = currentCard.value
return card ? formatFlowPair(card) : '暂无'
})
const expireText = computed(() => {
const card = currentCard.value
return card ? formatDateTime(card.flowPkgEndAt) : '-'
})
const statusText = computed(() => {
const card = currentCard.value
return card ? getBossCardStatusText(card.bossCardStatus) : '未选择'
})
function selectOption(index: number) {
selectedIndex.value = index
}
function handlePay() {
if (!canUseAddon.value) {
uni.showToast({
title: '暂无加油包订购权限',
icon: 'none',
})
return
}
uni.showModal({
title: '确认订购',
content: `卡号:${cardValueText.value}\n加油包:${selectedOption.value.name}\n应付金额:${selectedAmountText.value}\n订购后将立即生效。`,
confirmText: '确认',
cancelText: '再看看',
success: (res) => {
if (!res.confirm)
return
uni.showToast({
title: '已选择加油包',
icon: 'success',
})
},
})
}
onMounted(async () => {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canUseAddon.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能订购加油包', icon: 'none' })
}
})
</script>
<template>
<view class="biz-page">
<view class="biz-stack">
<view v-if="noPermission" class="biz-card">
<view class="biz-card__body text-26rpx text-#64748b leading-38rpx">
当前账号不能订购加油包。
</view>
</view>
<template v-else>
<view class="biz-hero">
<view class="biz-hero__top">
<view class="min-w-0 flex items-center gap-16rpx">
<view class="biz-hero__icon">
<view class="i-carbon-flash" />
</view>
<view class="min-w-0">
<text class="biz-hero__title">加油包</text>
<text class="biz-hero__sub">按需补充流量,选定后直接进入支付确认</text>
</view>
</view>
<wd-tag type="primary" variant="plain">
订购
</wd-tag>
</view>
<view class="biz-stat-grid">
<view class="biz-stat">
<text class="biz-stat__label">当前卡片</text>
<text class="biz-stat__value">{{ cardValueText }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">当前套餐</text>
<text class="biz-stat__value">{{ packageText }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">剩余流量</text>
<text class="biz-stat__value">{{ flowText }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">卡片状态</text>
<text class="biz-stat__value">{{ statusText }}</text>
</view>
</view>
</view>
<view class="biz-card">
<view class="biz-card__body">
<view class="biz-section-head">
<view>
<text class="biz-section-title">选择加油包</text>
<text class="biz-section-sub">按流量档位单选,价格与生效时间由后端统一控制</text>
</view>
<wd-tag type="success" variant="plain">
{{ selectedOption.name }}
</wd-tag>
</view>
<view class="grid gap-12rpx">
<view
v-for="(item, index) in addonOptions"
:key="item.name"
class="biz-option"
:class="{ 'is-active': index === selectedIndex }"
@click="selectOption(index)"
>
<view class="biz-option__main">
<view class="flex items-center gap-10rpx">
<text class="biz-option__title">{{ item.name }}</text>
<wd-tag
v-if="item.badge"
type="warning"
size="small"
variant="plain"
>
{{ item.badge }}
</wd-tag>
</view>
<text class="biz-option__meta">
{{ item.flowText }} · {{ item.desc }}
</text>
</view>
<text class="biz-option__price">{{ item.price }}</text>
<view class="biz-radio" />
</view>
</view>
</view>
</view>
<view class="biz-card">
<view class="biz-card__body">
<view class="biz-row">
<text class="biz-row__label">已选加油包</text>
<text class="biz-row__value">{{ selectedOption.name }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">付款方式</text>
<text class="biz-row__value">微信支付</text>
</view>
<view class="biz-row">
<text class="biz-row__label">生效时间</text>
<text class="biz-row__value">订购成功后立即生效</text>
</view>
<view class="biz-row">
<text class="biz-row__label">到期时间</text>
<text class="biz-row__value">{{ expireText }}</text>
</view>
</view>
</view>
</template>
</view>
</view>
<view v-if="!noPermission" class="biz-paybar">
<view class="biz-paybar__inner">
<view class="biz-paybar__summary">
<text class="biz-paybar__label">{{ cardValueText }}</text>
<text class="biz-paybar__value">应付 {{ selectedAmountText }}</text>
</view>
<wd-button block type="primary" @click="handlePay">
立即订购 {{ selectedAmountText }}
</wd-button>
</view>
</view>
</template>
@@ -0,0 +1,740 @@
<script lang="ts" setup>
import type { AllocateBatchRes, AllocateTarget, AppCard } from '@/api/card'
import {
allocateBatchPrepare,
allocateBatchSubmit,
getAllocateTargets,
getCardList,
} from '@/api/card'
import {
displayCardValue,
displayPackageName,
formatDateTime,
formatFlowPair,
} from '@/pages/card/data'
import { useUserStore } from '@/store/user'
import { hasUserPermission } from '@/utils/permission'
import { getAccountWorkspace } from '@/utils/userIdentity'
definePage({
style: {
navigationBarTitleText: '批量卡片划拨',
},
})
const userStore = useUserStore()
const keyword = ref('')
const targetKeyword = ref('')
const cards = ref<AppCard[]>([])
const targets = ref<AllocateTarget[]>([])
const selectedCardValues = ref<string[]>([])
const selectedTargetId = ref<number>()
const loadingCards = ref(false)
const loadingTargets = ref(false)
const preparing = ref(false)
const submitting = ref(false)
const noPermission = ref(false)
const errorMessage = ref('')
const targetPopupVisible = ref(false)
const prepare = ref<AllocateBatchRes>()
const result = ref<AllocateBatchRes>()
const initialCardValue = ref('')
let targetLoadSeq = 0
const workspace = computed(() => getAccountWorkspace(userStore.userInfo))
const isAgentUser = computed(() => workspace.value === 'enterprise' || workspace.value === 'agent' || workspace.value === 'admin')
const canUseAllocate = computed(() => isAgentUser.value && hasUserPermission(userStore.userInfo, 'allocate'))
const selectedTarget = computed(() =>
targets.value.find(item => Number(item.deptId) === Number(selectedTargetId.value)),
)
const allocatableCards = computed(() => cards.value.filter(item => item.canAllocate !== false))
const visibleCards = computed(() => {
const value = keyword.value.trim()
if (!value)
return allocatableCards.value
return allocatableCards.value.filter((item) => {
return (
allocateCardValue(item).includes(value)
|| (item.msisdn || '').includes(value)
|| (item.imsi || '').includes(value)
|| displayPackageName(item).includes(value)
|| (item.remark || '').includes(value)
|| (item.terminalName || '').includes(value)
|| ownerDeptText(item).includes(value)
)
})
})
const canPrepare = computed(
() => canUseAllocate.value && !!selectedTarget.value && !!selectedCardValues.value.length,
)
const canSubmit = computed(() => {
return !!prepare.value?.batchNo
&& !submitting.value
&& !result.value
&& (prepare.value.failCount || 0) === 0
&& prepare.value.status !== 'failed'
})
const isFixedCardMode = computed(() => !!initialCardValue.value)
async function openTargetPopup() {
if (noPermission.value)
return
targetPopupVisible.value = true
await refreshTargetPopup()
}
async function refreshTargetPopup() {
targetKeyword.value = ''
targets.value = []
await loadTargets('')
}
async function searchTargetPopup() {
await loadTargets(targetKeyword.value)
}
async function bootstrap() {
await userStore.fetchUserInfo().catch(() => undefined)
if (!canUseAllocate.value) {
noPermission.value = true
uni.showToast({ title: '当前账号不能使用卡片划拨', icon: 'none' })
setTimeout(() => {
uni.navigateBack()
}, 800)
return
}
await Promise.all([loadTargets(), loadCards()])
}
async function loadTargets(keyword?: string) {
const seq = ++targetLoadSeq
loadingTargets.value = true
try {
const actualKeyword = typeof keyword === 'string' ? keyword : targetKeyword.value
const res = await getAllocateTargets({
page: 1,
pageSize: 1000,
keyword: actualKeyword.trim() || undefined,
})
if (seq !== targetLoadSeq)
return
targets.value = (res.items || []).map(item => ({
...item,
deptId: Number(item.deptId),
parentId: item.parentId == null ? undefined : Number(item.parentId),
})).filter(isSelectableTarget)
if (
selectedTargetId.value
&& !targets.value.some(item => Number(item.deptId) === Number(selectedTargetId.value))
) {
selectedTargetId.value = undefined
}
}
catch (error) {
if (seq !== targetLoadSeq)
return
targets.value = []
uni.showToast({ title: getErrorMessage(error, '目标客户加载失败'), icon: 'none' })
}
finally {
if (seq === targetLoadSeq)
loadingTargets.value = false
}
}
async function loadCards() {
loadingCards.value = true
errorMessage.value = ''
try {
const res = await getCardList({
page: 1,
pageSize: 100,
cardValue: keyword.value.trim() || undefined,
})
cards.value = res.items || []
applyInitialCardSelection()
const nextKeys = new Set(allocatableCards.value.map(allocateCardValue))
selectedCardValues.value = selectedCardValues.value.filter(value => nextKeys.has(value))
}
catch (error) {
cards.value = []
selectedCardValues.value = []
errorMessage.value = getErrorMessage(error, '卡片列表加载失败')
}
finally {
loadingCards.value = false
}
}
function allocateCardValue(item: AppCard) {
return item.iccid || item.msisdn || item.imsi || item.imei || ''
}
function ownerDeptText(item: AppCard) {
return item.deptName || '-'
}
function cardMatchesValue(item: AppCard, value: string) {
return [item.iccid, item.msisdn, item.imsi, item.imei].filter(Boolean).includes(value)
}
function applyInitialCardSelection() {
const value = initialCardValue.value.trim()
if (!value)
return
const item = allocatableCards.value.find(cardItem => cardMatchesValue(cardItem, value))
if (!item)
return
const selectedValue = allocateCardValue(item)
if (selectedValue) {
selectedCardValues.value = [selectedValue]
}
}
function isSelected(item: AppCard) {
return selectedCardValues.value.includes(allocateCardValue(item))
}
function selectVisibleCards() {
const values = visibleCards.value.map(allocateCardValue).filter(Boolean)
selectedCardValues.value = Array.from(new Set([...selectedCardValues.value, ...values]))
}
function clearSelection() {
selectedCardValues.value = []
}
function selectTarget(item: AllocateTarget) {
selectedTargetId.value = Number(item.deptId)
targetPopupVisible.value = false
}
function targetLabel(item?: AllocateTarget) {
if (!item)
return '请选择划拨目标客户'
return item.label || item.deptName || '未命名客户'
}
function targetDescription(item?: AllocateTarget) {
if (!item)
return ''
return item.deptKey ? `客户编码:${item.deptKey}` : targetScopeName(item)
}
function targetScopeName(item?: AllocateTarget) {
return item?.targetScopeName || (item?.targetScope === 'direct_child' ? '直属下级' : '当前机构')
}
function targetScopeTagType(item?: AllocateTarget) {
return item?.targetScope === 'direct_child' ? 'primary' : 'success'
}
function isSelectableTarget(item: AllocateTarget) {
const currentDeptId = Number(userStore.userInfo?.deptId || userStore.userInfo?.enterpriseProfile?.deptId || 0)
if (currentDeptId && Number(item.deptId) === currentDeptId)
return false
if (item.targetScope === 'current')
return false
if (item.targetScope === 'direct_child')
return true
if (currentDeptId && Number(item.parentId) === currentDeptId)
return true
return String(item.targetScopeName || '').includes('下级')
}
function prepareStatusText(item?: AllocateBatchRes) {
if (!item)
return '-'
if (result.value)
return '已划拨'
if (item.status === 'failed' || (item.failCount || 0) > 0)
return '校验失败'
return '已校验'
}
function failedAllocateDetails(item?: AllocateBatchRes) {
return item?.details?.filter(detail => detail.status === 'failed') || []
}
function showPrepareFailedModal(item: AllocateBatchRes) {
const failedDetails = failedAllocateDetails(item)
const failCount = item.failCount || failedDetails.length
const lines = failedDetails.slice(0, 6).map((detail) => {
const cardNo = detail.itemId || `第${detail.rowNo}行`
return `${cardNo}:${detail.errorReason || '校验失败'}`
})
if (failCount > lines.length)
lines.push(`还有 ${failCount - lines.length} 条失败明细,请在页面下方查看。`)
uni.showModal({
title: `校验失败 ${failCount} 张`,
content: lines.join('\n') || item.failReason || '存在校验失败卡,请调整后重新校验。',
confirmText: '知道了',
showCancel: false,
})
}
async function handlePrepare() {
if (!canPrepare.value || preparing.value) {
uni.showToast({ title: '请先选择目标客户和需要划拨的卡', icon: 'none' })
return
}
preparing.value = true
try {
const res = await allocateBatchPrepare({
cardValues: selectedCardValues.value,
targetDeptId: selectedTargetId.value as number,
})
prepare.value = res
result.value = undefined
if ((res.failCount || 0) > 0 || res.status === 'failed') {
showPrepareFailedModal(res)
}
else {
uni.showToast({ title: '校验通过', icon: 'success' })
}
}
catch (error) {
prepare.value = undefined
uni.showToast({ title: getErrorMessage(error, '划拨校验失败'), icon: 'none' })
}
finally {
preparing.value = false
}
}
async function handleSubmit() {
if (!prepare.value?.batchNo || !selectedTarget.value) {
uni.showToast({ title: '请先校验划拨批次', icon: 'none' })
return
}
if (submitting.value)
return
const confirmed = await confirmAllocateSubmit()
if (!confirmed)
return
submitting.value = true
try {
const res = await allocateBatchSubmit({
batchNo: prepare.value.batchNo,
cardValues: selectedCardValues.value,
targetDeptId: selectedTarget.value.deptId,
})
result.value = res
prepare.value = res
uni.showModal({
title: '划拨成功',
content: `划拨批次:${res.batchNo || '-'}\n目标客户:${targetLabel(res.target)}\n划拨卡数:${res.quantity || selectedCardValues.value.length} 张\n当前企业保留查看和其他办理权限,划拨权交由目标客户。`,
showCancel: false,
})
initialCardValue.value = ''
clearSelection()
await loadCards()
}
catch (error) {
uni.showToast({ title: getErrorMessage(error, '划拨提交失败'), icon: 'none' })
}
finally {
submitting.value = false
}
}
function confirmAllocateSubmit() {
return new Promise<boolean>((resolve) => {
uni.showModal({
title: '确认划拨',
content: `目标客户:${targetLabel(selectedTarget.value)}\n划拨卡数:${prepare.value?.quantity || selectedCardValues.value.length} 张\n确认后更新卡片归属,当前企业保留查看和其他办理权限,划拨权交由目标客户。`,
confirmText: '确认划拨',
cancelText: '再看一下',
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
})
})
}
function getErrorMessage(error: unknown, fallback: string) {
if (error && typeof error === 'object') {
const data = error as {
msg?: unknown
message?: unknown
errMsg?: unknown
statusCode?: unknown
data?: { msg?: unknown, message?: unknown }
}
if (typeof data.msg === 'string' && data.msg)
return data.msg
if (typeof data.message === 'string' && data.message)
return data.message
if (typeof data.data?.msg === 'string' && data.data.msg)
return data.data.msg
if (typeof data.data?.message === 'string' && data.data.message)
return data.data.message
if (typeof data.statusCode === 'number')
return fallback
if (typeof data.errMsg === 'string' && data.errMsg)
return fallback
}
return fallback
}
watch([selectedCardValues, selectedTargetId], () => {
prepare.value = undefined
result.value = undefined
})
onMounted(() => {
void bootstrap()
})
onLoad((query) => {
const value = normalizeQueryValue(query?.cardValue || query?.cardNo || query?.iccid)
if (!value)
return
initialCardValue.value = value
keyword.value = value
})
function normalizeQueryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : String(value || '')
}
</script>
<template>
<scroll-view scroll-y class="h-screen">
<view class="biz-page">
<view class="biz-stack">
<view v-if="noPermission" class="biz-card">
<view class="biz-card__body text-26rpx text-#64748b leading-38rpx">
当前账号不能使用卡片划拨。
</view>
</view>
<template v-else>
<view class="biz-hero">
<view class="biz-hero__top">
<view class="min-w-0 flex items-center gap-16rpx">
<view class="biz-hero__icon">
<view class="i-carbon-user-multiple" />
</view>
<view class="min-w-0">
<text class="biz-hero__title">卡片划拨</text>
<text class="biz-hero__sub">选择可划拨卡片,校验后转给目标下级客户</text>
</view>
</view>
<view class="app-tag app-tag--info shrink-0 whitespace-nowrap">
代理商
</view>
</view>
<view class="biz-stat-grid">
<view class="biz-stat">
<text class="biz-stat__label">目标客户</text>
<text class="biz-stat__value">{{ targetLabel(selectedTarget) }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">已选卡片</text>
<text class="biz-stat__value">{{ selectedCardValues.length }} 张</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">校验状态</text>
<text class="biz-stat__value">{{ prepareStatusText(prepare) }}</text>
</view>
<view class="biz-stat">
<text class="biz-stat__label">失败卡数</text>
<text class="biz-stat__value">{{ prepare?.failCount || 0 }} 张</text>
</view>
</view>
</view>
<view class="biz-card">
<wd-cell
title="目标客户"
:value="targetLabel(selectedTarget)"
clickable
is-link
center
@click="openTargetPopup"
/>
</view>
<view class="biz-card">
<view class="biz-card__body">
<wd-input
v-model="keyword"
placeholder="搜索卡号、套餐、备注或客户"
:clearable="!isFixedCardMode"
@confirm="loadCards"
/>
<view class="biz-mini-action-grid mt-16rpx">
<wd-button size="small" type="primary" plain block custom-class="biz-mini-action-btn" :loading="loadingCards" @click="loadCards">
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-renew biz-mini-action-btn__icon biz-mini-action-btn__icon--blue" />
<text class="biz-mini-action-btn__text">刷新</text>
</view>
</wd-button>
<wd-button
size="small"
type="primary"
plain
block
custom-class="biz-mini-action-btn"
:disabled="!visibleCards.length"
@click="selectVisibleCards"
>
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-checkmark-outline biz-mini-action-btn__icon biz-mini-action-btn__icon--green" />
<text class="biz-mini-action-btn__text">全选</text>
</view>
</wd-button>
<wd-button
size="small"
type="primary"
plain
block
custom-class="biz-mini-action-btn"
:disabled="!selectedCardValues.length"
@click="clearSelection"
>
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-close biz-mini-action-btn__icon biz-mini-action-btn__icon--gray" />
<text class="biz-mini-action-btn__text">清空</text>
</view>
</wd-button>
</view>
</view>
</view>
<view class="biz-section-head">
<view>
<text class="biz-section-title">当前可划拨卡</text>
<text class="biz-section-sub">划拨前会生成 ALC 批次进行归属和状态校验</text>
</view>
<view class="app-tag app-tag--info shrink-0 whitespace-nowrap">
已选 {{ selectedCardValues.length }}
</view>
</view>
<view v-if="loadingCards" class="biz-card">
<view class="biz-card__body">
<wd-skeleton theme="paragraph" />
</view>
</view>
<view v-else-if="errorMessage" class="biz-card">
<view class="biz-card__body grid gap-16rpx">
<view class="text-26rpx text-#b91c1c leading-38rpx">
{{ errorMessage }}
</view>
<wd-button size="small" type="primary" @click="loadCards">
重试
</wd-button>
</view>
</view>
<view v-else class="biz-card">
<view v-if="visibleCards.length" class="biz-card__body">
<wd-checkbox-group v-model="selectedCardValues" type="square" checked-color="#2563eb">
<view class="grid gap-12rpx">
<view
v-for="item in visibleCards"
:key="allocateCardValue(item)"
class="biz-check-row"
:class="{ 'is-active': isSelected(item) }"
>
<wd-checkbox
:name="allocateCardValue(item)"
custom-label-class="!block !w-full"
>
<view class="min-w-0 pr-16rpx">
<view class="truncate text-30rpx text-#111827 font-900">
{{ displayCardValue(item) }}
</view>
<view class="mt-8rpx truncate text-24rpx text-#64748b">
{{ displayPackageName(item) }}
</view>
<view class="mt-8rpx text-22rpx text-#64748b leading-32rpx">
{{ formatFlowPair(item) }} · 到期 {{ formatDateTime(item.flowPkgEndAt) }}
</view>
<view class="mt-8rpx text-22rpx text-#64748b leading-32rpx">
归属客户:{{ ownerDeptText(item) }}
</view>
<view v-if="item.terminalName" class="mt-8rpx text-22rpx text-#64748b leading-32rpx">
终端绑定:{{ item.terminalName }}
</view>
</view>
</wd-checkbox>
</view>
</view>
</wd-checkbox-group>
</view>
<view v-else class="biz-card__body text-center text-26rpx text-#64748b">
暂无可划拨卡片
</view>
</view>
<view v-if="prepare" class="biz-card">
<view class="biz-card__body">
<view class="biz-section-head mb-8rpx">
<text class="biz-section-title">校验结果</text>
<wd-tag :type="prepare.failCount ? 'warning' : 'success'" variant="plain">
{{ prepareStatusText(prepare) }}
</wd-tag>
</view>
<view class="biz-row">
<text class="biz-row__label">划拨批次</text>
<text class="biz-row__value mono">{{ prepare.batchNo || '-' }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">目标客户</text>
<text class="biz-row__value">{{ targetLabel(prepare.target) }}</text>
</view>
<view class="biz-row">
<text class="biz-row__label">校验卡数</text>
<text class="biz-row__value">
{{ prepare.validCount ?? prepare.quantity ?? 0 }}/{{ prepare.totalCount ?? selectedCardValues.length }} 张
</text>
</view>
<view class="biz-row">
<text class="biz-row__label">失败卡数</text>
<text class="biz-row__value">{{ prepare.failCount || 0 }} 张</text>
</view>
</view>
<view
v-if="prepare.failCount"
class="border-t border-#eef2f7 border-solid px-24rpx py-20rpx"
>
<view class="mb-14rpx text-26rpx text-#991b1b font-800">
失败明细
</view>
<view class="grid gap-12rpx">
<view
v-for="item in failedAllocateDetails(prepare)"
:key="`${item.rowNo}-${item.itemId}`"
class="rounded-8rpx bg-#fef2f2 px-18rpx py-16rpx"
>
<view class="text-24rpx text-#7f1d1d font-700">
第 {{ item.rowNo }} 行:{{ item.itemId || '-' }}
</view>
<view class="mt-6rpx text-23rpx text-#991b1b leading-32rpx">
{{ item.errorReason || '校验失败' }}
</view>
</view>
</view>
</view>
</view>
</template>
<wd-popup v-model="targetPopupVisible" position="bottom" round safe-area-inset-bottom>
<view class="max-h-84vh flex flex-col p-24rpx">
<view class="text-32rpx text-#111827 font-900">
选择目标客户
</view>
<view class="mt-20rpx">
<wd-input
v-model="targetKeyword"
label="搜索"
label-width="72px"
placeholder="客户名称或编码"
clearable
@confirm="searchTargetPopup"
/>
<view class="mt-16rpx">
<wd-button
size="small"
type="primary"
plain
block
custom-class="biz-mini-action-btn"
:loading="loadingTargets"
@click="refreshTargetPopup"
>
<view class="biz-mini-action-btn__inner">
<view class="i-carbon-renew biz-mini-action-btn__icon biz-mini-action-btn__icon--blue" />
<text class="biz-mini-action-btn__text">刷新目标客户</text>
</view>
</wd-button>
</view>
</view>
<view class="mt-16rpx text-24rpx text-#64748b">
{{ loadingTargets ? '正在加载下级代理...' : `已加载 ${targets.length} 个下级代理` }}
</view>
<scroll-view scroll-y class="mt-20rpx h-620rpx min-h-260rpx">
<view v-if="targets.length" class="grid gap-12rpx">
<view
v-for="item in targets"
:key="item.deptId"
class="border rounded-8rpx border-solid p-20rpx"
:class="
Number(item.deptId) === Number(selectedTargetId)
? 'border-#2563eb bg-#eff6ff'
: 'border-#e5e7eb bg-#f8fafc'
"
@click="selectTarget(item)"
>
<view class="flex items-center justify-between gap-16rpx">
<view class="min-w-0">
<view class="truncate text-28rpx text-#111827 font-900">
{{ item.deptName }}
</view>
<view class="mt-8rpx text-23rpx text-#64748b">
{{ targetDescription(item) }}
</view>
</view>
<view class="flex shrink-0 items-center gap-8rpx">
<wd-tag :type="targetScopeTagType(item)" variant="plain">
{{ targetScopeName(item) }}
</wd-tag>
<wd-tag v-if="Number(item.deptId) === Number(selectedTargetId)" type="primary" variant="plain">
已选择
</wd-tag>
</view>
</view>
</view>
</view>
<view v-else class="p-32rpx text-center text-26rpx text-#64748b">
暂无可划拨下级代理
</view>
</scroll-view>
</view>
</wd-popup>
</view>
</view>
</scroll-view>
<view
v-if="!noPermission && !targetPopupVisible"
class="biz-paybar z-1100"
>
<view class="biz-paybar__inner">
<view class="biz-paybar__summary">
<text class="biz-paybar__label">已选 {{ selectedCardValues.length }} 张</text>
<text class="biz-paybar__value">
{{ selectedTarget ? `${targetScopeName(selectedTarget)} · ${targetLabel(selectedTarget)}` : '请选择目标客户' }}
</text>
</view>
<view class="biz-paybar-actions">
<wd-button
type="primary"
plain
block
custom-class="biz-paybar-btn"
:loading="preparing"
:disabled="!canPrepare || preparing"
@click="handlePrepare"
>
校验划拨
</wd-button>
<wd-button
type="primary"
block
custom-class="biz-paybar-btn"
:loading="submitting"
:disabled="!canSubmit"
@click="handleSubmit"
>
确认划拨
</wd-button>
</view>
</view>
</view>
</template>
@@ -0,0 +1,61 @@
import type {
BalanceRechargeMockPayParams,
BalanceRechargeRefundParams,
BalanceRechargeStatusParams,
BalanceRechargeStatusRes,
BalanceRechargeSubmitParams,
BalanceRechargeSubmitRes,
} from '@/api/types/balance'
import type { AppOrder } from '@/api/types/order'
import { http } from '@/http/http'
export type {
BalanceRechargeMockPayParams,
BalanceRechargeRefundParams,
BalanceRechargeStatusParams,
BalanceRechargeStatusRes,
BalanceRechargeSubmitParams,
BalanceRechargeSubmitRes,
} from '@/api/types/balance'
export function balanceRechargeSubmit(data: BalanceRechargeSubmitParams) {
return http.post<BalanceRechargeSubmitRes>(
'/balance/recharge-submit',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function balanceRechargeStatus(data: BalanceRechargeStatusParams) {
return http.get<BalanceRechargeStatusRes>('/balance/recharge-status', data, undefined, {
hideErrorToast: true,
})
}
export function balanceRechargeMockPay(data: BalanceRechargeMockPayParams) {
return http.post<BalanceRechargeStatusRes>(
'/balance/recharge-mock-pay',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function balanceRechargeRefund(data: BalanceRechargeRefundParams) {
return http.post<AppOrder>(
'/balance/recharge-refund',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
@@ -0,0 +1,27 @@
import type {
CustomerDetailParams,
CustomerDetailRes,
CustomerListParams,
CustomerListRes,
} from '@/api/types/customer'
import { http } from '@/http/http'
export type {
AppCustomer,
AppCustomerDetail,
AppCustomerType,
CustomerDetailParams,
CustomerListParams,
} from '@/api/types/customer'
export function getCustomerList(params: CustomerListParams) {
return http.get<CustomerListRes>('/customer/list', params, undefined, {
hideErrorToast: true,
})
}
export function getCustomerDetail(params: CustomerDetailParams) {
return http.get<CustomerDetailRes>('/customer/detail', params, undefined, {
hideErrorToast: true,
})
}
@@ -0,0 +1,44 @@
import type {
DiagnosisFlowParams,
DiagnosisFlowRes,
DiagnosisInfoParams,
DiagnosisInfoRes,
} from '@/api/types/diagnosis'
import { http } from '@/http/http'
export type {
DiagnosisFlowParams,
DiagnosisFlowRes,
DiagnosisInfoItem,
DiagnosisInfoParams,
DiagnosisInfoRes,
DiagnosisModuleItem,
DiagnosisPackageItem,
DiagnosisRateLimitApnItem,
DiagnosisRateLimitPccItem,
DiagnosisSessionItem,
} from '@/api/types/diagnosis'
export function getDiagnosisInfo(data: DiagnosisInfoParams) {
return http.post<DiagnosisInfoRes>('/diagnosis/info', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getOpenDiagnosisInfo(data: DiagnosisInfoParams & { tenantId?: string }) {
return http.post<DiagnosisInfoRes>('/open/diagnosis/info', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getDiagnosisFlow(data: DiagnosisFlowParams) {
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function getOpenDiagnosisFlow(data: DiagnosisFlowParams & { tenantId?: string }) {
return http.post<DiagnosisFlowRes>('/open/diagnosis/flow', data, undefined, undefined, {
hideErrorToast: true,
})
}
+21
View File
@@ -0,0 +1,21 @@
import type {
OrderDetailParams,
OrderDetailRes,
OrderListParams,
OrderListRes,
} from '@/api/types/order'
import { http } from '@/http/http'
export type { AppOrder, AppOrderItem, OrderDetailParams, OrderListParams } from '@/api/types/order'
export function getOrderList(params: OrderListParams) {
return http.get<OrderListRes>('/order/list', params, undefined, {
hideErrorToast: true,
})
}
export function getOrderDetail(params: OrderDetailParams) {
return http.get<OrderDetailRes>('/order/detail', params, undefined, {
hideErrorToast: true,
})
}
@@ -0,0 +1,81 @@
import type {
WarningConfigBatchNotifyParams,
WarningConfigBatchNotifyRes,
WarningConfigBatchSaveParams,
WarningConfigBatchSaveRes,
WarningConfigListParams,
WarningConfigListRes,
WarningConfigMetaRes,
WarningConfigNotifyParams,
WarningConfigNotifyRes,
WarningConfigSaveParams,
WarningConfigSaveRes,
} from '@/api/types/warning'
import { http } from '@/http/http'
export type {
WarningConfig,
WarningConfigBatchItemParams,
WarningConfigBatchNotifyParams,
WarningConfigBatchNotifyRes,
WarningConfigBatchSaveParams,
WarningConfigBatchSaveRes,
WarningConfigListRes,
WarningConfigNotifyParams,
WarningConfigNotifyRes,
WarningConfigSaveParams,
} from '@/api/types/warning'
export function getWarningConfigList(params: WarningConfigListParams) {
return http.get<WarningConfigListRes>('/warning/config/list', params, undefined, {
hideErrorToast: true,
})
}
export function saveWarningConfig(data: WarningConfigSaveParams) {
return http.post<WarningConfigSaveRes>('/warning/config/save', data, undefined, undefined, {
hideErrorToast: true,
})
}
export function updateWarningConfigNotify(data: WarningConfigNotifyParams) {
return http.post<WarningConfigNotifyRes>(
'/warning/config/notify',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function getWarningConfigMeta() {
return http.get<WarningConfigMetaRes>('/warning/config/meta', undefined, undefined, {
hideErrorToast: true,
})
}
export function saveWarningConfigBatch(data: WarningConfigBatchSaveParams) {
return http.post<WarningConfigBatchSaveRes>(
'/warning/config/batch-save',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
export function updateWarningConfigNotifyBatch(data: WarningConfigBatchNotifyParams) {
return http.post<WarningConfigBatchNotifyRes>(
'/warning/config/batch-notify',
data,
undefined,
undefined,
{
hideErrorToast: true,
},
)
}
@@ -0,0 +1,35 @@
import type {
WorkOrderCreateParams,
WorkOrderCreateRes,
WorkOrderDetailParams,
WorkOrderDetailRes,
WorkOrderListParams,
WorkOrderListRes,
} from '@/api/types/workOrder'
import { http } from '@/http/http'
export type {
AppWorkOrder,
AppWorkOrderCommunication,
WorkOrderCreateParams,
WorkOrderDetailParams,
WorkOrderListParams,
} from '@/api/types/workOrder'
export function getWorkOrderList(params: WorkOrderListParams) {
return http.get<WorkOrderListRes>('/work-order/list', params, undefined, {
hideErrorToast: true,
})
}
export function getWorkOrderDetail(params: WorkOrderDetailParams) {
return http.get<WorkOrderDetailRes>('/work-order/detail', params, undefined, {
hideErrorToast: true,
})
}
export function createWorkOrder(data: WorkOrderCreateParams) {
return http.post<WorkOrderCreateRes>('/work-order/create', data, undefined, undefined, {
hideErrorToast: true,
})
}

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