chore: 并入 iotlcm-app 历史到 app/(原提交 f40bda9)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:00 +08:00
1107 changed files with 147143 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# 统一使用 LF,避免跨平台协作时产生整文件 diff
* text=auto eol=lf
# 二进制文件不做行尾转换
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
*.ttf binary
*.pdf binary
*.exe binary
*.xlsx binary
+64
View File
@@ -0,0 +1,64 @@
name: mirror-to-gitee
# 把本仓同步到 Gitee 企业仓 snowgoat/<同名仓库>。
# 触发:main 分支推送、v* tag 推送、手动;另有每日兜底重试。
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
# Gitea Actions 的 cron 用 UTC:18:30 UTC = 次日 02:30 北京时间
- cron: '30 18 * * *'
workflow_dispatch:
concurrency:
group: mirror-${{ github.ref }}
cancel-in-progress: true
jobs:
mirror:
runs-on: host
steps:
- name: 同步到 Gitee
env:
SRC_TOKEN: ${{ secrets.SRC_GIT_TOKEN }}
DST_TOKEN: ${{ secrets.GITEE_TOKEN }}
DST_OWNER: ${{ secrets.GITEE_OWNER }}
DST_USER: ${{ secrets.GITEE_USER }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -u
if [ -z "${REPO_NAME:-}" ]; then
echo "未取到仓库名,退出"; exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "源:git.5iot.com/iotlcm-public/$REPO_NAME"
echo "目标:gitee.com/$DST_OWNER/$REPO_NAME"
git clone --mirror "https://jenkins:${SRC_TOKEN}@git.5iot.com/iotlcm-public/${REPO_NAME}.git" "$WORK/repo.git"
cd "$WORK/repo.git"
echo "本地 ref:$(git for-each-ref --format='%(refname:short)' | tr '\n' ' ')"
# Gitee 在推送密集时会返回 429 Pending queue overflowed / 408 Authorization server timeout,
# 属服务端限流,退避重试即可。
attempt=0
while [ "$attempt" -lt 5 ]; do
attempt=$((attempt + 1))
echo "----- 推送尝试 $attempt/5"
if git push --mirror "https://${DST_USER}:${DST_TOKEN}@gitee.com/${DST_OWNER}/${REPO_NAME}.git" 2>&1; then
echo "✅ 已同步到 Gitee"
exit 0
fi
wait_s=$((attempt * 30))
echo "推送失败,${wait_s}s 后重试"
sleep "$wait_s"
done
echo "❌ 连续 5 次推送失败,请检查 Gitee 状态或令牌"
exit 1
+80
View File
@@ -0,0 +1,80 @@
name: trigger-jenkins-oss
# 打 tag 时触发 Jenkins 构建开源镜像并推送到镜像仓库。
# 仅在 tag 推送时触发;main 分支的日常提交不触发。
on:
push:
tags:
- 'v*'
workflow_dispatch:
# 同一 ref 连续触发时取消旧任务,只跑最新的
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
trigger:
runs-on: host
steps:
- name: 触发 Jenkins 镜像构建
env:
JU: ${{ secrets.JENKINS_USER }}
JT: ${{ secrets.JENKINS_API_TOKEN }}
JURL: ${{ secrets.JENKINS_URL }}
JOB_PATH: ${{ secrets.JENKINS_JOB_PATH }}
REPO_NAME: ${{ github.event.repository.name }}
TAG_NAME: ${{ github.ref_name }}
run: |
set -eu
# 只有 tag 推送才构建;手动触发时按 tag 取,没有就退出
case "${GITHUB_REF:-}" in
refs/tags/v*) ;;
*)
if [ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]; then
echo "非 tag 推送,跳过:${GITHUB_REF:-unknown}"
exit 0
fi
;;
esac
if [ -z "${TAG_NAME:-}" ]; then
echo "未取到 tag 名,退出"
exit 1
fi
# job 在 Jenkins 文件夹下的路径(组织密钥 JENKINS_JOB_PATH),
# 例如 job/iotlcm-opensrc/job/iotlcm-oss-build。
# 用密钥而非硬编码,Jenkins 里移动 job 时不用改 11 个仓。
JP="${JOB_PATH:-job/iotlcm-oss-build}"
echo "仓库:$REPO_NAME 版本:$TAG_NAME job:$JP"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
# 取 CSRF crumb
curl -s -c "$WORK/cj" -u "$JU:$JT" "$JURL/crumbIssuer/api/json" -o "$WORK/crumb.json"
CR=$(python3 -c "import json;print(json.load(open('$WORK/crumb.json'))['crumb'])")
# 触发参数化任务:SERVICES=仓库名,VERSION=tag
code=$(curl -s -o "$WORK/out" -w '%{http_code}' -X POST -b "$WORK/cj" -u "$JU:$JT" \
-H "Jenkins-Crumb: $CR" \
--data-urlencode "SERVICES=$REPO_NAME" \
--data-urlencode "VERSION=$TAG_NAME" \
--data-urlencode "FORCE=true" \
"$JURL/$JP/buildWithParameters")
echo "Jenkins 触发 HTTP $code"
if [ "$code" != "201" ] && [ "$code" != "200" ] && [ "$code" != "302" ] && [ "$code" != "303" ]; then
echo "触发失败:"
cat "$WORK/out"
exit 1
fi
LINK="$JURL/$JP/"
echo "=========================================="
echo "Jenkins 构建:$LINK"
echo "=========================================="
echo "::notice title=Jenkins 镜像构建::[$LINK]($LINK)"
+70
View File
@@ -0,0 +1,70 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.hbuilderx
.stylelintcache
.eslintcache
/docs/
src/types
# 单独把这个文件排除掉,用以解决部分电脑生成的 auto-import.d.ts 的API不完整导致类型提示报错问题
!src/types/auto-import.d.ts
src/manifest.json
src/pages.json
# 2025-10-15 by 菲鸽: lock 文件还是需要加入版本管理,今天又遇到版本不一致导致无法运行的问题了。
# pnpm-lock.yaml
# package-lock.json
# TIPS:如果某些文件已经加入了版本管理,现在重新加入 .gitignore 是不生效的,需要执行下面的操作
# `git rm -r --cached .` 然后提交 commit 即可。
# git rm -r --cached file1 file2 ## 针对某些文件
# git rm -r --cached dir1 dir2 ## 针对某些文件夹
# git rm -r --cached . ## 针对所有文件
# 更新 uni-app 官方版本
# npx @dcloudio/uvm@latest
*.log
*.exe
*.tar
*.zip
.vscode
.cursor
# Local upload files generated by the backend.
server/resource/upload/
server/resource/certs/*
!server/resource/certs/
server/upload/
server/.env
server/xiujie-wechat/
# Deployment host runtime configuration.
deploy/.env
# Local tooling / preview artifacts (not part of source)
.workbuddy/
app/.card-dom.html
app/.preview-*.png
app/_tmp_*
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+109
View File
@@ -0,0 +1,109 @@
# iotlcm-app
iotLCM 物联网卡全生命周期管理平台的**小程序端 + 用户端服务**。
- `app/` —— uni-app 小程序前端(基于 [unibest](https://github.com/unibest-tech/unibest),MIT)
- `server/` —— 用户端 GoFrame 服务(基于 XiuAdmin 框架,Apache-2.0)
配套管理后台:[iotlcm-admin-svr](https://git.5iot.com/iotlcm-public/iotlcm-admin-svr) / [iotlcm-admin-ui](https://git.5iot.com/iotlcm-public/iotlcm-admin-ui)
## 特性
**小程序端**(`app/`)
- 公开查卡:输入卡号即可查状态、流量、到期时间
- 卡片详情与用量曲线(日/月分库)
- 一键诊断:拍卡号或手输,问题排查直达
- 账号体系:组织下个人账号、角色权限
**用户端服务**(`server/`)
- 小程序端的业务后端:卡映射、诊断、订单、余额、消息、工单
- 独立于管理后台的用户域数据与权限
- 对接上游通道完成真实查卡与诊断
## 技术栈
- 前端:uni-app + Vue 3 + TypeScript + Vite 5 + UnoCSS + wot-ui + z-paging
- 后端:Go 1.25 + GoFrame v2 + MySQL + Redis
## 快速开始
### 后端
将 `server/config/config.example.yaml` 复制为 `server/config/config.yaml` 并填写自己的凭据;使用代码生成前同样将 `server/hack/config.example.yaml` 复制为 `server/hack/config.yaml`。
```bash
cd server
go mod tidy
go run .
```
默认监听 `:8199`。
### 小程序前端
```bash
cd app
pnpm install
pnpm dev:mp-weixin # 微信小程序,产物用微信开发者工具打开 dist/dev/mp-weixin
```
## 配置
| 位置 | 说明 |
| --- | --- |
| `server/config/config.example.yaml` | 后端配置(**公开版凭据为占位符**) |
| `app/env/.env*` | 前端环境变量(**公开版 AppID 为占位符**) |
后端配置占位:
| 配置项 | 占位 |
| --- | --- |
| `database.default.link` | `mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)` |
| `redis.default.address` / `pass` | `${REDIS_ADDR}` / `${REDIS_PASSWORD}` |
| `jwt.signingKey` | `${JWT_SIGNING_KEY}` |
前端配置占位:
| 变量 | 占位 |
| --- | --- |
| `VITE_WX_APPID` | `YOUR_WX_APPID` |
| `VITE_UNI_APPID` | `__UNI__YOURAPPID` |
| `VITE_MP_SERVER_BASEURL` | `https://admin.iotlcm.example.com/app/api/v1` |
## 目录结构
```text
iotlcm-app/
├── app/ # uni-app 小程序前端
│ ├── src/pages/ # 页面(首页、查卡、诊断、登录等)
│ ├── src/constant/ # 常量与路由定义
│ ├── env/ # 环境变量
│ └── src/static/logo.svg # 应用图标
└── server/ # GoFrame 用户端服务
├── api/ # API 契约(app / common / gen)
├── internal/
│ ├── logic/app/ # 用户端业务逻辑
│ ├── model/ consts/ # 模型与常量
│ └── utility/ # 工具(含启动 banner)
├── config/ # 配置
└── storage/data/ # 框架基座初始化数据
```
## 初始化数据
`server/storage/data/xiu_admin.sql` 是框架基座初始化数据(系统菜单、字典、角色、示例账号),是运行起点的必要输入。
> **数据库 schema 不随本仓发布** —— 生产库的完整 DDL 已过时,请按需自建表结构。
## 上游与许可
| 部分 | 上游 | 许可 |
| --- | --- | --- |
| `app/` | [unibest](https://github.com/unibest-tech/unibest) | MIT(见 [`app/LICENSE`](./app/LICENSE)) |
| `server/` | XiuAdmin(GoFrame 中后台框架基座) | Apache-2.0(见 [`server/LICENSE`](./server/LICENSE)) |
## License
根目录 [LICENSE](./LICENSE) 为 Apache-2.0。
+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
}
+134
View File
@@ -0,0 +1,134 @@
# Build context: app/
# Example:
# docker build -f Dockerfile -t iotlcm-app-web:latest .
# 基础镜像仓库。默认使用 Docker Hub 官方镜像;内网环境可替换为自己的仓库:
# docker build --build-arg BASE_REGISTRY=harbor.example.com/library ...
ARG BASE_REGISTRY=docker.io/library
FROM ${BASE_REGISTRY}/node:24-alpine AS builder
WORKDIR /build/app
COPY package.json pnpm-lock.yaml .npmrc ./
COPY patches ./patches
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=/app/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 ${BASE_REGISTRY}/nginx:1.30-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,
},
})
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.2',
'versionCode': '102',
'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,
},
])
+227
View File
@@ -0,0 +1,227 @@
{
"name": "iotlcm-app-ui",
"type": "module",
"version": "1.0.2",
"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": "xxx@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",
"test:run": "vitest run",
"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",
"vitest": "3.2.4",
"vue-tsc": "^3.0.6"
},
"pnpm": {
"overrides": {
"unconfig": "7.3.2"
},
"patchedDependencies": {
"@wot-ui/ui@2.1.0": "patches/@wot-ui__ui@2.1.0.patch"
}
},
"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,
})
+87
View File
@@ -0,0 +1,87 @@
diff --git a/components/wd-button/wd-button.vue b/components/wd-button/wd-button.vue
index 8b2610d90c8ebe78e2726179bb83f2b688ec0d96..c25b26965ccfb9470cc3c2ab9e3b2a1576c3220d 100644
--- a/components/wd-button/wd-button.vue
+++ b/components/wd-button/wd-button.vue
@@ -18,7 +18,7 @@
]"
:hover-start-time="hoverStartTime"
:hover-stay-time="hoverStayTime"
- :open-type="openTypeValue"
+ :open-type="openTypeValue as UniHelper.ButtonOpenType"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
diff --git a/components/wd-fab/types.ts b/components/wd-fab/types.ts
index 50ca94f1a95a54c95fe68d9ed3535b45f1e36bcc..57b5ceb1143a0e9385cc280fe8054ed87f255807 100644
--- a/components/wd-fab/types.ts
+++ b/components/wd-fab/types.ts
@@ -87,7 +87,7 @@ export const fabProps = {
*/
gap: {
type: Object as PropType<FabGap>,
- default: () => ({})
+ default: (): FabGap => ({})
},
/**
* 点击时是否展开菜单
diff --git a/components/wd-swiper/wd-swiper.vue b/components/wd-swiper/wd-swiper.vue
index c662e565171dad9c350b7215dddd3e8fc11320ab..54a5260a40ed1d6554a7e1b2158f94d8598b1c10 100644
--- a/components/wd-swiper/wd-swiper.vue
+++ b/components/wd-swiper/wd-swiper.vue
@@ -158,9 +158,9 @@ const swiperIndicator = computed(() => {
indicatorPosition: indicatorPosition || 'bottom'
}
if (isObj(indicator)) {
- swiperIndicator.type = indicator.type || 'dots'
- swiperIndicator.minShowNum = indicator.minShowNum || 2
- swiperIndicator.showControls = indicator.showControls || false
+ swiperIndicator.type = (indicator as Partial<SwiperNavProps>).type || 'dots'
+ swiperIndicator.minShowNum = (indicator as Partial<SwiperNavProps>).minShowNum || 2
+ swiperIndicator.showControls = (indicator as Partial<SwiperNavProps>).showControls || false
}
return swiperIndicator
})
diff --git a/components/wd-tour/wd-tour.vue b/components/wd-tour/wd-tour.vue
index d1c6e895a819a4e15178930c3869a7034d741887..2eacba3bb4b20621d94465d17a94d88ccea6195e 100644
--- a/components/wd-tour/wd-tour.vue
+++ b/components/wd-tour/wd-tour.vue
@@ -69,7 +69,7 @@ export default {
<script lang="ts" setup>
import { ref, computed, watch, nextTick, type CSSProperties } from 'vue'
import { addUnit, getRect, getSystemInfo, isDef, objToStyle } from '../../common/util'
-import { tourProps, type TourEmits, type TourExpose } from './types'
+import { tourProps, type TourEmits, type TourExpose, type TourStep } from './types'
import { useRaf } from '../../composables/useRaf'
import { useTranslate } from '../../composables/useTranslate'
import wdButton from '../wd-button/wd-button.vue'
@@ -122,7 +122,7 @@ const highlightClass = computed(() => {
})
// 计算属性
-const currentStep = computed(() => {
+const currentStep = computed<Partial<TourStep>>(() => {
return props.steps[currentIndex.value] || {}
})
// 提取公共的默认样式函数
diff --git a/composables/useUpload.ts b/composables/useUpload.ts
index acad05a461562cb197299623100371280d559918..83b02be95ebb380b8d1cd9f03a742a8727ece502 100644
--- a/composables/useUpload.ts
+++ b/composables/useUpload.ts
@@ -174,7 +174,7 @@ export function useUpload(): UseUploadReturn {
path: item.path || '',
name: item.name || '',
size: item.size,
- type: 'image',
+ type: 'image' as const,
thumb: item.path || ''
}))
}
@@ -183,7 +183,7 @@ export function useUpload(): UseUploadReturn {
path: (res.tempFiles as any).path || '',
name: (res.tempFiles as any).name || '',
size: (res.tempFiles as any).size,
- type: 'image',
+ type: 'image' as const,
thumb: (res.tempFiles as any).path || ''
}
]
+22293
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 }
},
}
}
+49
View File
@@ -0,0 +1,49 @@
<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>
+52
View File
@@ -0,0 +1,52 @@
<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'
import { useMessageStore } from '@/store/message'
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) => {
void useMessageStore().refreshUnreadCount()
// 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,
},
)
}
+220
View File
@@ -0,0 +1,220 @@
import type {
AllocateBatchPrepareParams,
AllocateBatchRes,
AllocateBatchSubmitParams,
AllocateTargetListParams,
AllocateTargetListRes,
BindCardParams,
CardLifecycleParams,
CardLifecycleRes,
CardLifecycleStatusParams,
CardListParams,
CardListRes,
CardQueryRes,
CardRecognitionRes,
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)
}
interface CardRecognitionPayload {
code?: number
message?: string
data?: CardRecognitionRes
}
function parseCardRecognitionPayload(data: unknown): CardRecognitionPayload {
if (data && typeof data === 'object')
return data as CardRecognitionPayload
const text = String(data ?? '').replace(/^\uFEFF/, '').trim()
return text ? JSON.parse(text) as CardRecognitionPayload : {}
}
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: CardRecognitionPayload
try {
payload = parseCardRecognitionPayload(response.data)
}
catch {
const headers = (response as typeof response & { header?: Record<string, string> }).header
const contentType = headers?.['content-type'] || headers?.['Content-Type']
const responseType = contentType ? `,${contentType}` : ''
reject(new Error(`图片识别响应格式错误(HTTP ${response.statusCode}${responseType})`))
return
}
if (response.statusCode < 200 || response.statusCode >= 300 || Number(payload.code) !== 0) {
reject(new Error(payload.message || '图片识别失败'))
return
}
resolve(payload.data || {
code: 'RECOGNITION_FAILED',
message: '图片识别结果为空',
cardType: 'iccid',
cardValue: '',
confidence: 0,
candidates: [],
})
},
fail: (error) => {
reject(new Error(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>('/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,
})
}
+57
View File
@@ -0,0 +1,57 @@
import { http } from '@/http/http'
export interface AllocationUnits {
sourceDeptId?: number
sourceDeptName?: string
targetDeptId?: number
targetDeptName?: string
targetDeptMode?: 'single' | 'per_row'
}
export function allocationUnitName(name?: string, id?: number) {
return name || (id ? '单位已删除或信息未记录' : '单位信息未记录')
}
export function receivingUnitName(item: AllocationUnits) {
return item.targetDeptMode === 'per_row' ? '按卡片明细接收' : allocationUnitName(item.targetDeptName, item.targetDeptId)
}
export interface ExecutionLog extends AllocationUnits {
batchNo: string
batchType: number
sourceChannel: string
executionMode: number
sourceType: number | null
batchAsyncStatus: number
totalCount: number
runResult: number
failReason?: string
createdAt?: string
startTime?: string
endTime?: string
}
export interface ExecutionDetail extends AllocationUnits {
itemId: string
rowNo: number
detailStatus: number
errorCode?: string
errorReason?: string
}
export const channelLabels: Record<string, string> = { console: '控制台', mini_program: '小程序', api: '开放API', system: '系统任务', legacy: '历史未知' }
export const modeLabels = ['未知', '单卡', '批量']
export const inputLabels = ['选择', '连号', '输入', '文件', '定时任务']
export const statusLabels = ['已创建', '待处理', '处理中', '成功', '失败', '部分成功']
export const detailLabels = ['待处理', '处理中', '成功', '失败']
export function listExecutionLogs(params: Record<string, unknown>) {
return http.get<{ items: ExecutionLog[], total: number }>('/execution-log/list', params, undefined, { hideErrorToast: true })
}
export function executionSummary(batchNo: string) {
return http.get<ExecutionLog>('/execution-log/summary', { batchNo }, undefined, { hideErrorToast: true })
}
export function executionDetails(params: Record<string, unknown>) {
return http.get<{ items: ExecutionDetail[], total: number }>('/execution-log/details', params, undefined, { hideErrorToast: true })
}
export function allocateSummary(params: { batchNo?: string, requestId?: string }) {
return http.get<ExecutionLog>('/card/allocate-result', params, undefined, { hideErrorToast: true })
}
export function allocateDetails(params: Record<string, unknown>) {
return http.get<{ items: ExecutionDetail[], total: number }>('/card/allocate-details', params, 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' })
}
+180
View File
@@ -0,0 +1,180 @@
import type {
IAuthLoginRes,
ICheckSideCaptchaForm,
IForgotPasswordForm,
ILoginForm,
IPhoneSmsLoginForm,
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 phoneSmsLogin(data: IPhoneSmsLoginForm) {
return http.post<IAuthLoginRes>(
'/auth/phone-login',
data,
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)),
})
})
}
export interface WarningPreference {
enabled: boolean
wechatBound: boolean
}
export function getWarningPreference() {
return http.get<WarningPreference>('/user/wechat/warning-preference', undefined, undefined, AUTH_REQUEST_OPTIONS)
}
export function saveWarningPreference(enabled: boolean) {
return http.post<WarningPreference>('/user/wechat/warning-preference', { enabled }, undefined, undefined, AUTH_REQUEST_OPTIONS)
}
export function unbindWechat() {
return http.post<IWechatBindRes>('/user/wechat/unbind', {}, undefined, undefined, AUTH_REQUEST_OPTIONS)
}
+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>
}
+399
View File
@@ -0,0 +1,399 @@
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
allocateOnly?: boolean
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 {
executionMode?: number
cardValues: string[]
targetDeptId: number
}
export interface AllocateBatchSubmitParams {
requestId?: string
executionMode?: number
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 {
requestId?: string
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
}
+60
View File
@@ -0,0 +1,60 @@
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
cardStatusCounts?: { status: number, count: number }[]
packageStatusCounts?: { status: number, count: 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
summary: {
total: number
normalCount: number
pendingProfileCount: number
}
}
export interface CustomerDetailParams {
customerType: Exclude<AppCustomerType, 'all'> | string
customerId: number
}
export interface CustomerDetailRes {
customer: AppCustomerDetail
}
+108
View File
@@ -0,0 +1,108 @@
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
imei?: string
status?: string
dataStatus?: string
dataServiceSource?: 'apn' | 'network_block'
netBlockStatus?: string
netBlockReason?: 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
reason?: 'not_subscribed' | 'unsupported' | 'no_data' | 'query_error' | string
message?: string
cached?: boolean
updatedAt?: string
}
export interface DiagnosisFlowRes {
times: string[]
flow: number[]
flowAvailable?: boolean[]
unit: string
granularity?: 'day' | 'month' | string
hasData?: boolean
}
+254
View File
@@ -0,0 +1,254 @@
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 IPhoneSmsLoginForm {
tenantId: string
phone: string
smsCode: string
selectedUserId?: number
}
export interface IWechatLoginForm {
tenantId: string
wxCode: string
}
export interface IWechatBindPhoneForm {
tenantId: string
bindTicket: string
phone: string
smsCode: 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 {
isTopLevelEnterprise?: boolean
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' | 'login' | 'forgotPassword' | 'wechatBindPhone'
captchaKey: string
captchaPoint: string
}
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
}
+129
View File
@@ -0,0 +1,129 @@
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
activeConfigCount?: number
notifyConfigCount?: 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
}
+95
View File
@@ -0,0 +1,95 @@
export interface WorkOrderListParams {
scope?: 'all' | 'submitted' | 'received'
page: number
pageSize: number
keyword?: string
currentStatus?: number
woType?: number
}
export interface WorkOrderDetailParams {
beforeId?: number
woNo: string
}
export interface WorkOrderCreateParams {
creatorPhone: string
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 type WorkOrderAction = 'reply' | 'reopen' | 'confirm' | 'cancel' | 'evaluate' | 'transfer' | 'resolve' | 'close'
export interface WorkOrderActionParams {
woNo: string
action: WorkOrderAction
content?: string
attachmentUrls?: string[]
satisfactionLevel?: number
}
export interface WorkOrderProgress {
id: number
operationType: number
operationText: string
beforeStatus: number
afterStatus: number
createdAt?: string
}
export interface AppWorkOrder {
attachmentNames?: Record<string, string>
isCreator?: boolean
hasMoreCommunications?: boolean
attachmentUrls?: string[]
actions: WorkOrderAction[]
records?: WorkOrderProgress[]
resolveTime?: string
closeTime?: string
satisfactionLevel: number
evalContent?: string
evalTime?: string
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,
},
)
}
+41
View File
@@ -0,0 +1,41 @@
import type {
WorkOrderActionParams,
WorkOrderCreateParams,
WorkOrderCreateRes,
WorkOrderDetailParams,
WorkOrderDetailRes,
WorkOrderListParams,
WorkOrderListRes,
} from './types/workOrder'
import { http } from '@/http/http'
export type {
AppWorkOrder,
AppWorkOrderCommunication,
WorkOrderActionParams,
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,
})
}
export function actOnWorkOrder(data: WorkOrderActionParams) {
return http.post('/work-order/action', data, undefined, undefined, { hideErrorToast: true })
}
View File
+114
View File
@@ -0,0 +1,114 @@
<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>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { isWorkOrderImage } from '@/utils/workOrderAttachment'
const props = defineProps<{ url: string, name: string }>()
const emit = defineEmits<{ open: [] }>()
const failed = ref(false)
const loaded = ref(false)
const isImage = computed(() => isWorkOrderImage(props.url, props.name))
watch(() => props.url, () => {
failed.value = false
loaded.value = false
})
</script>
<template>
<view v-if="isImage" class="w-full overflow-hidden rounded-12rpx bg-#f1f5f9" @click="emit('open')">
<view class="relative h-176rpx w-full">
<view v-if="!loaded || failed" class="absolute inset-0 flex flex-col items-center justify-center gap-8rpx text-#64748b">
<wd-icon name="image" size="28" />
<text class="text-22rpx">{{ failed ? '点击查看原图' : '图片加载中' }}</text>
</view>
<image
v-if="!failed"
:src="url"
mode="aspectFill"
class="relative h-176rpx w-full"
@load="loaded = true"
@error="failed = true"
/>
</view>
<view class="truncate px-12rpx py-10rpx text-22rpx text-#475569">
{{ name }}
</view>
</view>
<view v-else class="w-full overflow-hidden rounded-12rpx bg-#f1f5f9">
<wd-cell :title="name" icon="file" is-link custom-class="!bg-transparent" custom-title-class="!truncate !text-24rpx" @click="emit('open')" />
</view>
</template>
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs'
import { createContext, runInContext } from 'node:vm'
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
import { describe, expect, it, vi } from 'vitest'
import { workOrderAttachmentName } from '@/utils/workOrderAttachment'
const source = readFileSync(new URL('./WorkOrderAttachments.vue', import.meta.url), 'utf8')
describe('工单上传原始文件名', () => {
it('微信上传携带原名,并保留URL字符串协议', async () => {
const emit = vi.fn()
const upload = vi.fn((options) => {
options.success({ statusCode: 200, data: JSON.stringify({ code: 0, data: { url: 'https://files/random.pdf', originalName: '说明.pdf' } }) })
})
const context = createContext({
exports: {},
require: (name: string) => name === '@/utils/workOrderAttachment' ? { workOrderAttachmentName } : name === '@/utils' ? { getEnvBaseUrl: () => 'https://api/api/v1' } : {},
defineProps: () => ({ modelValue: [], disabled: false }),
defineEmits: () => emit,
ref: (value: unknown) => ({ value }),
uni: { uploadFile: upload, showToast: vi.fn() },
})
const script = source.match(/<script[^>]*>([\s\S]*?)<\/script>/)?.[1]
if (!script)
throw new Error('未找到附件上传脚本')
runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2020 } }).outputText, context)
await runInContext('upload("wxfile://random.pdf", "说明.pdf", 20)', context)
expect(upload).toHaveBeenCalledWith(expect.objectContaining({ url: '/work-order/upload', formData: { fileType: 'file', originalName: '说明.pdf' } }))
expect(emit).toHaveBeenCalledWith('update:modelValue', ['https://files/random.pdf'])
expect(runInContext('nameOf("https://files/random.pdf")', context)).toBe('说明.pdf')
expect(source).toContain('form.append(\'originalName\', name)')
})
})
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { useTokenStore } from '@/store/token'
import { getEnvBaseUrl } from '@/utils'
import { workOrderAttachmentName } from '@/utils/workOrderAttachment'
const props = defineProps<{ modelValue: string[], disabled?: boolean }>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
'uploading': [value: boolean]
}>()
const uploading = ref(false)
const attachmentNames = ref<Record<string, string>>({})
function nameOf(url: string) {
return workOrderAttachmentName(url, attachmentNames.value[url])
}
async function upload(file: string | File, name: string, size?: number) {
if (uploading.value || props.disabled)
return
if ((size || 0) > 10 * 1024 * 1024) {
uni.showToast({ title: '单个附件不能超过10MB', icon: 'none' })
return
}
uploading.value = true
emit('uploading', true)
try {
let body: { code: number, msg?: string, message?: string, data?: { url?: string, path?: string, originalName?: string } }
// #ifdef H5
if (typeof file !== 'string') {
const form = new FormData()
form.append('file', file, name)
form.append('fileType', 'file')
form.append('originalName', name)
const token = useTokenStore().updateNowTime().validToken
const response = await fetch(`${getEnvBaseUrl()}/work-order/upload`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
body: form,
})
if (!response.ok)
throw new Error('附件上传失败')
body = await response.json()
}
else {
// #endif
body = await new Promise((resolve, reject) => {
uni.uploadFile({
url: '/work-order/upload',
filePath: file as string,
name: 'file',
formData: { fileType: 'file', originalName: name },
success: (res) => {
try {
if (res.statusCode < 200 || res.statusCode >= 300)
throw new Error('附件上传失败')
resolve(JSON.parse(res.data))
}
catch (error) { reject(error) }
},
fail: reject,
})
})
// #ifdef H5
}
// #endif
if (Number(body.code) !== 0)
throw new Error(body.message || body.msg || '附件上传失败')
const url = body.data?.url || body.data?.path
if (!url)
throw new Error('上传接口未返回文件地址')
attachmentNames.value[url] = body.data?.originalName || name
emit('update:modelValue', [...props.modelValue, url])
}
catch (error) {
uni.showToast({ title: error instanceof Error ? error.message : '附件上传失败', icon: 'none' })
}
finally {
uploading.value = false
emit('uploading', false)
}
}
function chooseFile() {
// #ifdef H5
const input = document.createElement('input')
input.type = 'file'
input.onchange = () => {
const file = input.files?.[0]
if (file)
void upload(file, file.name, file.size)
}
input.click()
return
// #endif
// #ifdef MP-WEIXIN
uni.chooseMessageFile({
count: 1,
type: 'all',
success: (res) => {
const file = res.tempFiles[0]
if (file)
void upload(file.path, file.name, file.size)
},
})
return
// #endif
// #ifndef H5
// #ifndef MP-WEIXIN
uni.showToast({ title: '当前平台请使用图片附件', icon: 'none' })
// #endif
// #endif
}
function choose() {
if (uploading.value || props.disabled)
return
if (props.modelValue.length >= 6) {
uni.showToast({ title: '最多上传6个附件', icon: 'none' })
return
}
uni.showActionSheet({
itemList: ['拍照或选择图片', '选择文件'],
success: ({ tapIndex }) => {
if (tapIndex === 1) {
chooseFile()
return
}
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const files = res.tempFiles as UniApp.ChooseImageSuccessCallbackResult['tempFiles']
const file = Array.isArray(files) ? files[0] : undefined
const path = res.tempFilePaths[0]
if (path)
void upload(path, workOrderAttachmentName(path), file?.size)
},
})
},
})
}
</script>
<template>
<view class="flex flex-col gap-12rpx">
<view class="flex items-center justify-between gap-16rpx">
<text class="text-24rpx text-#64748b">附件 {{ modelValue.length }}/6,单个不超过10MB</text>
<wd-button size="small" type="primary" variant="text" icon="add" :disabled="disabled || modelValue.length >= 6" :loading="uploading" @click="choose">
添加附件
</wd-button>
</view>
<view v-for="(url, index) in modelValue" :key="`${url}-${index}`" class="flex items-center justify-between gap-16rpx">
<text class="min-w-0 flex-1 truncate text-24rpx text-#334155">{{ nameOf(url) }}</text>
<wd-button size="small" type="danger" variant="text" :disabled="disabled || uploading" @click="emit('update:modelValue', modelValue.filter((_, i) => i !== index))">
移除
</wd-button>
</view>
</view>
</template>
+87
View File
@@ -0,0 +1,87 @@
/*******************************
* 此文件由脚本自动生成,请勿手动修改 *
*******************************/
/**
* 页面路径常量
*/
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',
/** 批次执行日志 */
'UserViewsExecutionLog': '/pages/user/views/executionLog/index',
/** 执行日志详情 */
'UserViewsExecutionLogDetail': '/pages/user/views/executionLogDetail/index',
/** 帮助与反馈 */
'UserViewsFeedback': '/pages/user/views/feedback/index',
/** 使用手册 */
'UserViewsManual': '/pages/user/views/manual/index',
/** 消息提醒 */
'UserViewsNotification': '/pages/user/views/notification/index',
/** 个人资料 */
'UserViewsProfile': '/pages/user/views/profile/index',
/** 实名信息 */
'UserViewsRealName': '/pages/user/views/realName/index',
}
export default RouterPath
+33
View File
@@ -0,0 +1,33 @@
/**
* v1.0.2 front-end release scope (legacy export names retained).
*
* Business, customer management and authorized allocation are now available.
* Other unreleased lifecycle actions remain hidden. The API layer is unchanged;
* these switches keep every front-end entry point consistent.
*/
export const V101_RELEASE = {
showWechatLogin: false,
showRegister: false,
showBindCard: false,
showCardMoreActions: false,
showBusinessTab: true,
showUserBusinessEntries: false,
showDiagnosisFlowForNonAdmin: false,
} as const
/** Routes that must remain unreachable from direct front-end navigation. */
export const V101_HIDDEN_ROUTE_PATHS = new Set([
'/pages/business/views/addon/index',
'/pages/business/views/balanceRecharge/index',
'/pages/business/views/cardRenew/index',
'/pages/business/views/change/index',
'/pages/business/views/order/index',
'/pages/business/views/renew/index',
'/pages/business/views/warning/index',
'/pages/card/views/packageDetail/index',
'/pages/card/views/warning/index',
])
export function isV101HiddenRoute(path: string) {
return V101_HIDDEN_ROUTE_PATHS.has(path)
}
+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
+39
View File
@@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { afterEach, describe, expect, it, vi } from 'vitest'
const config = vi.hoisted(() => ({ baseUrl: '/app/api/v1' }))
vi.mock('@/utils', () => ({ getEnvBaseUrl: () => config.baseUrl }))
vi.mock('@/store', () => ({
useTokenStore: () => ({ updateNowTime: () => ({ validToken: 'test-token' }) }),
}))
afterEach(() => {
vi.unstubAllGlobals()
vi.resetModules()
})
describe('工单附件上传地址', () => {
it.each([
'/app/api/v1',
'http://localhost:8010/api/v1',
'https://example.com/app/api/v1',
])('基础地址 %s 只拼接一次,并保留鉴权', async (baseUrl) => {
config.baseUrl = baseUrl
const addInterceptor = vi.fn()
vi.stubGlobal('uni', { addInterceptor })
const { requestInterceptor } = await import('./interceptor')
requestInterceptor.install()
const uploadInterceptor = addInterceptor.mock.calls.find(([name]) => name === 'uploadFile')?.[1]
expect(uploadInterceptor).toBeDefined()
const options = uploadInterceptor.invoke({ url: '/work-order/upload' })
expect(options.url).toBe(`${baseUrl}/work-order/upload`)
expect(options.header.Authorization).toBe('Bearer test-token')
})
it('原生上传交由拦截器拼接,H5 fetch 保留基础地址', () => {
const source = readFileSync(new URL('../components/WorkOrderAttachments.vue', import.meta.url), 'utf8')
expect(source).toMatch(/uni\.uploadFile\(\{\s*url: '\/work-order\/upload'/)
expect(source).toMatch(/fetch\(`\$\{getEnvBaseUrl\(\)\}\/work-order\/upload`/)
})
})
+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|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.2",
"versionCode": "102",
"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/"
}
}
}
+367
View File
@@ -0,0 +1,367 @@
{
"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": "工单列表",
"enablePullDownRefresh": true
}
},
{
"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": "实名信息"
}
},
{
"path": "manual/index",
"type": "page",
"style": {
"navigationBarTitleText": "使用手册"
}
},
{
"path": "executionLog/index",
"type": "page",
"style": {
"navigationBarTitleText": "批次执行日志"
}
},
{
"path": "executionLogDetail/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"
}
]
}
}

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