diff --git a/app/.gitattributes b/app/.gitattributes
new file mode 100644
index 0000000..33f3c22
--- /dev/null
+++ b/app/.gitattributes
@@ -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
diff --git a/app/.gitea/workflows/mirror-to-gitee.yml b/app/.gitea/workflows/mirror-to-gitee.yml
new file mode 100644
index 0000000..b8f8b52
--- /dev/null
+++ b/app/.gitea/workflows/mirror-to-gitee.yml
@@ -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
diff --git a/app/.gitea/workflows/trigger-jenkins.yml b/app/.gitea/workflows/trigger-jenkins.yml
new file mode 100644
index 0000000..f58a800
--- /dev/null
+++ b/app/.gitea/workflows/trigger-jenkins.yml
@@ -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)"
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..8e68b2b
--- /dev/null
+++ b/app/.gitignore
@@ -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_*
diff --git a/app/LICENSE b/app/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/app/LICENSE
@@ -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.
diff --git a/app/README.md b/app/README.md
new file mode 100644
index 0000000..9b45d40
--- /dev/null
+++ b/app/README.md
@@ -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。
diff --git a/app/app/.commitlintrc.cjs b/app/app/.commitlintrc.cjs
new file mode 100644
index 0000000..98ee7df
--- /dev/null
+++ b/app/app/.commitlintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['@commitlint/config-conventional'],
+}
diff --git a/app/app/.dockerignore b/app/app/.dockerignore
new file mode 100644
index 0000000..a67db47
--- /dev/null
+++ b/app/app/.dockerignore
@@ -0,0 +1,11 @@
+.git
+.idea
+.hbuilderx
+node_modules/
+dist/
+unpackage/
+.vite/
+.stylelintcache
+.eslintcache
+*.log
+*.local
diff --git a/app/app/.editorconfig b/app/app/.editorconfig
new file mode 100644
index 0000000..7f09864
--- /dev/null
+++ b/app/app/.editorconfig
@@ -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 # 关闭末尾空格修剪
diff --git a/app/app/.gitignore b/app/app/.gitignore
new file mode 100644
index 0000000..6a8cecc
--- /dev/null
+++ b/app/app/.gitignore
@@ -0,0 +1,6 @@
+node_modules
+private.*.key
+private.key
+
+# Local Claude API credentials
+scripts/claude-instances.local.ps1
diff --git a/app/app/.npmrc b/app/app/.npmrc
new file mode 100644
index 0000000..f47ca59
--- /dev/null
+++ b/app/app/.npmrc
@@ -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
diff --git a/app/app/.prettierignore b/app/app/.prettierignore
new file mode 100644
index 0000000..33fc9db
--- /dev/null
+++ b/app/app/.prettierignore
@@ -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
diff --git a/app/app/.prettierrc.json b/app/app/.prettierrc.json
new file mode 100644
index 0000000..0965e7e
--- /dev/null
+++ b/app/app/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "trailingComma": "all",
+ "printWidth": 100
+}
diff --git a/app/app/Dockerfile b/app/app/Dockerfile
new file mode 100644
index 0000000..d69440e
--- /dev/null
+++ b/app/app/Dockerfile
@@ -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;"]
diff --git a/app/app/LICENSE b/app/app/LICENSE
new file mode 100644
index 0000000..9e91d10
--- /dev/null
+++ b/app/app/LICENSE
@@ -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.
diff --git a/app/app/README.md b/app/app/README.md
new file mode 100644
index 0000000..d609221
--- /dev/null
+++ b/app/app/README.md
@@ -0,0 +1,66 @@
+
+
+
+
+iotlcm-app-ui
+
+
+
+iotLCM 物联网卡全生命周期管理平台的**小程序端**。
+
+基于 [unibest](https://github.com/unibest-tech/unibest)(MIT License)二次开发。
+
+[](https://nodejs.org)
+
+
+
+## 技术栈
+
+`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)
diff --git a/app/app/env/.env b/app/app/env/.env
new file mode 100644
index 0000000..f353265
--- /dev/null
+++ b/app/app/env/.env
@@ -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
diff --git a/app/app/env/.env.development b/app/app/env/.env.development
new file mode 100644
index 0000000..80a112a
--- /dev/null
+++ b/app/app/env/.env.development
@@ -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'
diff --git a/app/app/env/.env.production b/app/app/env/.env.production
new file mode 100644
index 0000000..493de00
--- /dev/null
+++ b/app/app/env/.env.production
@@ -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'
diff --git a/app/app/env/.env.test b/app/app/env/.env.test
new file mode 100644
index 0000000..a2627df
--- /dev/null
+++ b/app/app/env/.env.test
@@ -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'
diff --git a/app/app/eslint.config.mjs b/app/app/eslint.config.mjs
new file mode 100644
index 0000000..426985a
--- /dev/null
+++ b/app/app/eslint.config.mjs
@@ -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 `
diff --git a/app/app/src/App.vue b/app/app/src/App.vue
new file mode 100644
index 0000000..b3b59ee
--- /dev/null
+++ b/app/app/src/App.vue
@@ -0,0 +1,52 @@
+
+
+
diff --git a/app/app/src/api/balance.ts b/app/app/src/api/balance.ts
new file mode 100644
index 0000000..b391bec
--- /dev/null
+++ b/app/app/src/api/balance.ts
@@ -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(
+ '/balance/recharge-submit',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
+
+export function balanceRechargeStatus(data: BalanceRechargeStatusParams) {
+ return http.get('/balance/recharge-status', data, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function balanceRechargeMockPay(data: BalanceRechargeMockPayParams) {
+ return http.post(
+ '/balance/recharge-mock-pay',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
+
+export function balanceRechargeRefund(data: BalanceRechargeRefundParams) {
+ return http.post(
+ '/balance/recharge-refund',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
diff --git a/app/app/src/api/card.ts b/app/app/src/api/card.ts
new file mode 100644
index 0000000..8fdf89e
--- /dev/null
+++ b/app/app/src/api/card.ts
@@ -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('/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((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 }).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('/card/list', params, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function getPackageList(params: PackageListParams) {
+ return http.get('/card/package-list', params, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function publicPackageDetail(data: PublicPackageDetailParams) {
+ return http.post(
+ '/card/public-package-detail',
+ data,
+ undefined,
+ undefined,
+ CARD_REQUEST_OPTIONS,
+ )
+}
+
+export function getCardDetail(cardValue: string) {
+ return http.get('/card/detail', { cardValue }, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function bindCard(data: BindCardParams) {
+ return http.post('/card/bind', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function restartCard(data: RestartCardParams) {
+ return http.post('/card/restart', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function stopCard(data: RestartCardParams) {
+ return http.post('/card/stop', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function startCard(data: RestartCardParams) {
+ return http.post('/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(
+ '/card/lifecycle-batch-submit',
+ data,
+ undefined,
+ undefined,
+ CARD_REQUEST_OPTIONS,
+ )
+}
+
+export function lifecycleBatchStatus(data: CardLifecycleStatusParams) {
+ return http.get('/card/lifecycle-batch-status', data, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function renewPrepare(data: RenewPrepareParams) {
+ return http.post('/card/renew-prepare', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function renewBatchPrepare(data: RenewBatchPrepareParams) {
+ return http.post('/card/renew-batch-prepare', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function renewSubmit(data: RenewSubmitParams) {
+ return http.post('/card/renew-submit', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function renewBatchSubmit(data: RenewBatchSubmitParams) {
+ return http.post('/card/renew-batch-submit', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function getAllocateTargets(params: AllocateTargetListParams) {
+ return http.get('/card/allocate-targets', params, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function allocateBatchPrepare(data: AllocateBatchPrepareParams) {
+ return http.post(
+ '/card/allocate-batch-prepare',
+ data,
+ undefined,
+ undefined,
+ CARD_REQUEST_OPTIONS,
+ )
+}
+
+export function allocateBatchSubmit(data: AllocateBatchSubmitParams) {
+ return http.post(
+ '/card/allocate-batch-submit',
+ data,
+ undefined,
+ undefined,
+ CARD_REQUEST_OPTIONS,
+ )
+}
+
+export function changePrepare(data: ChangePrepareParams) {
+ return http.post('/card/change-prepare', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
+
+export function changeSubmit(data: ChangeSubmitParams) {
+ return http.post('/card/change-submit', data, undefined, undefined, CARD_REQUEST_OPTIONS)
+}
diff --git a/app/app/src/api/customer.ts b/app/app/src/api/customer.ts
new file mode 100644
index 0000000..8b751fd
--- /dev/null
+++ b/app/app/src/api/customer.ts
@@ -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('/customer/list', params, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function getCustomerDetail(params: CustomerDetailParams) {
+ return http.get('/customer/detail', params, undefined, {
+ hideErrorToast: true,
+ })
+}
diff --git a/app/app/src/api/diagnosis.ts b/app/app/src/api/diagnosis.ts
new file mode 100644
index 0000000..a9a1362
--- /dev/null
+++ b/app/app/src/api/diagnosis.ts
@@ -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('/diagnosis/info', data, undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function getOpenDiagnosisInfo(data: DiagnosisInfoParams & { tenantId?: string }) {
+ return http.post('/open/diagnosis/info', data, undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function getDiagnosisFlow(data: DiagnosisFlowParams) {
+ return http.post('/diagnosis/flow', data, undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function getOpenDiagnosisFlow(data: DiagnosisFlowParams & { tenantId?: string }) {
+ return http.post('/open/diagnosis/flow', data, undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
diff --git a/app/app/src/api/executionLog.ts b/app/app/src/api/executionLog.ts
new file mode 100644
index 0000000..c48ec38
--- /dev/null
+++ b/app/app/src/api/executionLog.ts
@@ -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 = { console: '控制台', mini_program: '小程序', api: '开放API', system: '系统任务', legacy: '历史未知' }
+export const modeLabels = ['未知', '单卡', '批量']
+export const inputLabels = ['选择', '连号', '输入', '文件', '定时任务']
+export const statusLabels = ['已创建', '待处理', '处理中', '成功', '失败', '部分成功']
+export const detailLabels = ['待处理', '处理中', '成功', '失败']
+export function listExecutionLogs(params: Record) {
+ return http.get<{ items: ExecutionLog[], total: number }>('/execution-log/list', params, undefined, { hideErrorToast: true })
+}
+export function executionSummary(batchNo: string) {
+ return http.get('/execution-log/summary', { batchNo }, undefined, { hideErrorToast: true })
+}
+export function executionDetails(params: Record) {
+ 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('/card/allocate-result', params, undefined, { hideErrorToast: true })
+}
+export function allocateDetails(params: Record) {
+ return http.get<{ items: ExecutionDetail[], total: number }>('/card/allocate-details', params, undefined, { hideErrorToast: true })
+}
diff --git a/app/app/src/api/foo-alova.ts b/app/app/src/api/foo-alova.ts
new file mode 100644
index 0000000..bd5ca7f
--- /dev/null
+++ b/app/app/src/api/foo-alova.ts
@@ -0,0 +1,16 @@
+import { http } from '@/http/alova'
+
+export interface IFoo {
+ id: number
+ name: string
+}
+
+export function foo() {
+ return http.Get('/foo', {
+ params: {
+ name: '菲鸽',
+ page: 1,
+ pageSize: 10,
+ },
+ })
+}
diff --git a/app/app/src/api/foo.ts b/app/app/src/api/foo.ts
new file mode 100644
index 0000000..a500002
--- /dev/null
+++ b/app/app/src/api/foo.ts
@@ -0,0 +1,43 @@
+import { http } from '@/http/http'
+
+export interface IFoo {
+ id: number
+ name: string
+}
+
+export function foo() {
+ return http.Get('/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('/foo', { name })
+}
+/** GET 请求;支持 传递 header 的范例 */
+export function getFooAPI2(name: string) {
+ return http.get('/foo', { name }, { 'Content-Type-100': '100' })
+}
+
+/** POST 请求 */
+export function postFooAPI(name: string) {
+ return http.post('/foo', { name })
+}
+/** POST 请求;需要传递 query 参数的范例;微信小程序经常有同时需要query参数和body参数的场景 */
+export function postFooAPI2(name: string) {
+ return http.post('/foo', { name }, { a: 1, b: 2 })
+}
+/** POST 请求;支持 传递 header 的范例 */
+export function postFooAPI3(name: string) {
+ return http.post('/foo', { name }, { a: 1, b: 2 }, { 'Content-Type-100': '100' })
+}
diff --git a/app/app/src/api/login.ts b/app/app/src/api/login.ts
new file mode 100644
index 0000000..dbf4f33
--- /dev/null
+++ b/app/app/src/api/login.ts
@@ -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(
+ '/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('/auth/sms', data, undefined, undefined, AUTH_REQUEST_OPTIONS)
+}
+
+export function login(loginForm: ILoginForm) {
+ return http.post(
+ '/auth/login',
+ loginForm,
+ undefined,
+ undefined,
+ AUTH_REQUEST_OPTIONS,
+ )
+}
+
+export function phoneSmsLogin(data: IPhoneSmsLoginForm) {
+ return http.post(
+ '/auth/phone-login',
+ data,
+ undefined,
+ undefined,
+ AUTH_REQUEST_OPTIONS,
+ )
+}
+
+export function wechatLogin(data: IWechatLoginForm) {
+ return http.post(
+ '/auth/wechat-login',
+ data,
+ undefined,
+ undefined,
+ AUTH_REQUEST_OPTIONS,
+ )
+}
+
+export function wechatBindPhone(data: IWechatBindPhoneForm) {
+ return http.post(
+ '/auth/wechat-bind-phone',
+ data,
+ undefined,
+ undefined,
+ AUTH_REQUEST_OPTIONS,
+ )
+}
+
+export function register(registerForm: IRegisterForm) {
+ return http.post(
+ '/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('/user/info', undefined, undefined, AUTH_REQUEST_OPTIONS)
+}
+
+export function logout() {
+ return Promise.resolve()
+}
+
+export function updateInfo(data: IUpdateInfo) {
+ return http.post('/user/info', data, undefined, undefined, AUTH_REQUEST_OPTIONS)
+}
+
+export function bindWechat(data: IWechatBindForm) {
+ return http.post(
+ '/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((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('/user/wechat/warning-preference', undefined, undefined, AUTH_REQUEST_OPTIONS)
+}
+
+export function saveWarningPreference(enabled: boolean) {
+ return http.post('/user/wechat/warning-preference', { enabled }, undefined, undefined, AUTH_REQUEST_OPTIONS)
+}
+
+export function unbindWechat() {
+ return http.post('/user/wechat/unbind', {}, undefined, undefined, AUTH_REQUEST_OPTIONS)
+}
diff --git a/app/app/src/api/message.ts b/app/app/src/api/message.ts
new file mode 100644
index 0000000..fbf1a8b
--- /dev/null
+++ b/app/app/src/api/message.ts
@@ -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('/message/list', params, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export async function getMessageCenter(params: MessageCenterParams = {}): Promise {
+ 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('/message/detail', params, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function readMessages(data: AppMessageReadParams) {
+ return http.post('/message/read', data, undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
diff --git a/app/app/src/api/order.ts b/app/app/src/api/order.ts
new file mode 100644
index 0000000..fb88a3b
--- /dev/null
+++ b/app/app/src/api/order.ts
@@ -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('/order/list', params, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function getOrderDetail(params: OrderDetailParams) {
+ return http.get('/order/detail', params, undefined, {
+ hideErrorToast: true,
+ })
+}
diff --git a/app/app/src/api/system-normalize.ts b/app/app/src/api/system-normalize.ts
new file mode 100644
index 0000000..5f99235
--- /dev/null
+++ b/app/app/src/api/system-normalize.ts
@@ -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 []
+}
diff --git a/app/app/src/api/system.test.ts b/app/app/src/api/system.test.ts
new file mode 100644
index 0000000..ddaa0b4
--- /dev/null
+++ b/app/app/src/api/system.test.ts
@@ -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'])
+ })
+})
diff --git a/app/app/src/api/system.ts b/app/app/src/api/system.ts
new file mode 100644
index 0000000..3ab1028
--- /dev/null
+++ b/app/app/src/api/system.ts
@@ -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('/system/auth/codes', params, undefined, {
+ hideErrorToast: true,
+ }).then(normalizeAccessCodesResponse)
+}
diff --git a/app/app/src/api/types/balance.ts b/app/app/src/api/types/balance.ts
new file mode 100644
index 0000000..f29ac43
--- /dev/null
+++ b/app/app/src/api/types/balance.ts
@@ -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
+}
+
+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
+}
diff --git a/app/app/src/api/types/card.ts b/app/app/src/api/types/card.ts
new file mode 100644
index 0000000..210f376
--- /dev/null
+++ b/app/app/src/api/types/card.ts
@@ -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
+}
diff --git a/app/app/src/api/types/customer.ts b/app/app/src/api/types/customer.ts
new file mode 100644
index 0000000..2f62134
--- /dev/null
+++ b/app/app/src/api/types/customer.ts
@@ -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 | string
+ customerId: number
+}
+
+export interface CustomerDetailRes {
+ customer: AppCustomerDetail
+}
diff --git a/app/app/src/api/types/diagnosis.ts b/app/app/src/api/types/diagnosis.ts
new file mode 100644
index 0000000..8e19f78
--- /dev/null
+++ b/app/app/src/api/types/diagnosis.ts
@@ -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
+ 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
+}
diff --git a/app/app/src/api/types/login.ts b/app/app/src/api/types/login.ts
new file mode 100644
index 0000000..1c38eac
--- /dev/null
+++ b/app/app/src/api/types/login.ts
@@ -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)
+
+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
+}
diff --git a/app/app/src/api/types/message.ts b/app/app/src/api/types/message.ts
new file mode 100644
index 0000000..1bae040
--- /dev/null
+++ b/app/app/src/api/types/message.ts
@@ -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']
+}
diff --git a/app/app/src/api/types/order.ts b/app/app/src/api/types/order.ts
new file mode 100644
index 0000000..124f179
--- /dev/null
+++ b/app/app/src/api/types/order.ts
@@ -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
+}
diff --git a/app/app/src/api/types/warning.ts b/app/app/src/api/types/warning.ts
new file mode 100644
index 0000000..c87c90e
--- /dev/null
+++ b/app/app/src/api/types/warning.ts
@@ -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
+}
diff --git a/app/app/src/api/types/workOrder.ts b/app/app/src/api/types/workOrder.ts
new file mode 100644
index 0000000..7e5afd0
--- /dev/null
+++ b/app/app/src/api/types/workOrder.ts
@@ -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
+ 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
+}
diff --git a/app/app/src/api/warning.ts b/app/app/src/api/warning.ts
new file mode 100644
index 0000000..6b20acd
--- /dev/null
+++ b/app/app/src/api/warning.ts
@@ -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('/warning/config/list', params, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function saveWarningConfig(data: WarningConfigSaveParams) {
+ return http.post('/warning/config/save', data, undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function updateWarningConfigNotify(data: WarningConfigNotifyParams) {
+ return http.post(
+ '/warning/config/notify',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
+
+export function getWarningConfigMeta() {
+ return http.get('/warning/config/meta', undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function saveWarningConfigBatch(data: WarningConfigBatchSaveParams) {
+ return http.post(
+ '/warning/config/batch-save',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
+
+export function updateWarningConfigNotifyBatch(data: WarningConfigBatchNotifyParams) {
+ return http.post(
+ '/warning/config/batch-notify',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
diff --git a/app/app/src/api/workOrder.ts b/app/app/src/api/workOrder.ts
new file mode 100644
index 0000000..6b29393
--- /dev/null
+++ b/app/app/src/api/workOrder.ts
@@ -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('/work-order/list', params, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function getWorkOrderDetail(params: WorkOrderDetailParams) {
+ return http.get('/work-order/detail', params, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function createWorkOrder(data: WorkOrderCreateParams) {
+ return http.post('/work-order/create', data, undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function actOnWorkOrder(data: WorkOrderActionParams) {
+ return http.post('/work-order/action', data, undefined, undefined, { hideErrorToast: true })
+}
diff --git a/app/app/src/components/.gitkeep b/app/app/src/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/app/app/src/components/Navbar/index.vue b/app/app/src/components/Navbar/index.vue
new file mode 100644
index 0000000..d6cf890
--- /dev/null
+++ b/app/app/src/components/Navbar/index.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
diff --git a/app/app/src/components/SideCaptcha.vue b/app/app/src/components/SideCaptcha.vue
new file mode 100644
index 0000000..37aa849
--- /dev/null
+++ b/app/app/src/components/SideCaptcha.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 验证码暂时无法显示
+ {{ errorMessage || '请重新获取验证码' }}
+
+ 重新获取
+
+
+
+
+
+
diff --git a/app/app/src/components/WorkOrderAttachmentPreview.vue b/app/app/src/components/WorkOrderAttachmentPreview.vue
new file mode 100644
index 0000000..7ff5a22
--- /dev/null
+++ b/app/app/src/components/WorkOrderAttachmentPreview.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+ {{ failed ? '点击查看原图' : '图片加载中' }}
+
+
+
+
+ {{ name }}
+
+
+
+
+
+
diff --git a/app/app/src/components/WorkOrderAttachments.test.ts b/app/app/src/components/WorkOrderAttachments.test.ts
new file mode 100644
index 0000000..afffaf9
--- /dev/null
+++ b/app/app/src/components/WorkOrderAttachments.test.ts
@@ -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(/
+
+
+
+
+ 附件 {{ modelValue.length }}/6,单个不超过10MB
+
+ 添加附件
+
+
+
+ {{ nameOf(url) }}
+ i !== index))">
+ 移除
+
+
+
+
diff --git a/app/app/src/constant/RouterPath.ts b/app/app/src/constant/RouterPath.ts
new file mode 100644
index 0000000..ca795e3
--- /dev/null
+++ b/app/app/src/constant/RouterPath.ts
@@ -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
diff --git a/app/app/src/constant/release.ts b/app/app/src/constant/release.ts
new file mode 100644
index 0000000..c6fc6b3
--- /dev/null
+++ b/app/app/src/constant/release.ts
@@ -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)
+}
diff --git a/app/app/src/env.d.ts b/app/app/src/env.d.ts
new file mode 100644
index 0000000..216e2f6
--- /dev/null
+++ b/app/app/src/env.d.ts
@@ -0,0 +1,39 @@
+///
+///
+
+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)
+ updateData(options?: Record): void
+ showToolTip(event: unknown, options?: Record): 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
+}
diff --git a/app/app/src/hooks/useRequest.ts b/app/app/src/hooks/useRequest.ts
new file mode 100644
index 0000000..8ac4bfe
--- /dev/null
+++ b/app/app/src/hooks/useRequest.ts
@@ -0,0 +1,54 @@
+import type { Ref } from 'vue'
+import { ref } from 'vue'
+
+interface IUseRequestOptions {
+ /** 是否立即执行 */
+ immediate?: boolean
+ /** 初始化数据 */
+ initialData?: T
+}
+
+interface IUseRequestReturn {
+ loading: Ref
+ error: Ref
+ data: Ref
+ run: (args?: P) => Promise
+}
+
+/**
+ * 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(
+ func: (args?: P) => Promise,
+ options: IUseRequestOptions = { immediate: false },
+): IUseRequestReturn {
+ const loading = ref(false)
+ const error = ref(false)
+ const data = ref(options.initialData) as Ref
+ 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)({} as P)
+ }
+ return { loading, error, data, run }
+}
diff --git a/app/app/src/hooks/useScroll.md b/app/app/src/hooks/useScroll.md
new file mode 100644
index 0000000..bb2eace
--- /dev/null
+++ b/app/app/src/hooks/useScroll.md
@@ -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
+
+
+
+
+ {{ item.name }}
+
+
+
+ 加载中...
+ 没有更多了
+ 加载失败,请重试
+
+
+
+
+
+
+
+```
+
+## 实现步骤总结
+
+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` 状态,在列表底部显示不同的提示信息,提升用户体验。
+
+通过以上步骤,你就可以在项目中快速集成一个功能完善、体验良好的上拉刷新和下拉加载列表。
\ No newline at end of file
diff --git a/app/app/src/hooks/useScroll.ts b/app/app/src/hooks/useScroll.ts
new file mode 100644
index 0000000..1563223
--- /dev/null
+++ b/app/app/src/hooks/useScroll.ts
@@ -0,0 +1,74 @@
+import type { Ref } from 'vue'
+import { onMounted, ref } from 'vue'
+
+interface UseScrollOptions {
+ fetchData: (page: number, pageSize: number) => Promise
+ pageSize?: number
+}
+
+interface UseScrollReturn {
+ list: Ref
+ loading: Ref
+ finished: Ref
+ error: Ref
+ refresh: () => Promise
+ loadMore: () => Promise
+}
+
+export function useScroll({
+ fetchData,
+ pageSize = 10,
+}: UseScrollOptions): UseScrollReturn {
+ const list = ref([]) as Ref
+ const loading = ref(false)
+ const finished = ref(false)
+ const error = ref(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,
+ }
+}
diff --git a/app/app/src/hooks/useUpload.ts b/app/app/src/hooks/useUpload.ts
new file mode 100644
index 0000000..7c9700a
--- /dev/null
+++ b/app/app/src/hooks/useUpload.ts
@@ -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 {
+ formData?: Record
+ maxSize?: number
+ accept?: T extends 'image' ? TImage[] : TFile[]
+ fileType?: T
+ success?: (params: any) => void
+ error?: (err: any) => void
+}
+
+export default function useUpload(options: TOptions = {} as TOptions) {
+ const {
+ formData = {},
+ maxSize = 5 * 1024 * 1024,
+ accept = ['*'],
+ fileType = 'image',
+ success,
+ error: onError,
+ } = options
+
+ const loading = ref(false)
+ const error = ref(null)
+ const data = ref(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
+ 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,
+ })
+}
diff --git a/app/app/src/http/README.md b/app/app/src/http/README.md
new file mode 100644
index 0000000..5bb5a4f
--- /dev/null
+++ b/app/app/src/http/README.md
@@ -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号)。
\ No newline at end of file
diff --git a/app/app/src/http/alova.ts b/app/app/src/http/alova.ts
new file mode 100644
index 0000000..84940f6
--- /dev/null
+++ b/app/app/src/http/alova.ts
@@ -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
diff --git a/app/app/src/http/error.ts b/app/app/src/http/error.ts
new file mode 100644
index 0000000..25780d5
--- /dev/null
+++ b/app/app/src/http/error.ts
@@ -0,0 +1,7 @@
+export {
+ getHttpErrorMessage,
+ NETWORK_ERROR_MESSAGE,
+ normalizeErrorText,
+ REQUEST_ERROR_MESSAGE,
+ toUserRequestError,
+} from './tools/enum'
diff --git a/app/app/src/http/http.ts b/app/app/src/http/http.ts
new file mode 100644
index 0000000..ce80c87
--- /dev/null
+++ b/app/app/src/http/http.ts
@@ -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(options: CustomRequestOptions) {
+ return new Promise((resolve, reject) => {
+ let settled = false
+ let requestTask: UniApp.RequestTask | undefined
+ let timeoutTimer: ReturnType | undefined
+ const timeout = resolveRequestTimeout(options.timeout)
+
+ function clearRequestTimeout() {
+ if (timeoutTimer) {
+ clearTimeout(timeoutTimer)
+ timeoutTimer = undefined
+ }
+ }
+
+ function finishResolve(value: T | PromiseLike) {
+ 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
+ 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(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(url: string, query?: Record, header?: Record, options?: Partial) {
+ return http({
+ url,
+ query,
+ method: 'GET',
+ header,
+ ...options,
+ })
+}
+
+export function httpPost(url: string, data?: Record, query?: Record, header?: Record, options?: Partial) {
+ return http({
+ url,
+ query,
+ data,
+ method: 'POST',
+ header,
+ ...options,
+ })
+}
+
+export function httpPut(url: string, data?: Record, query?: Record, header?: Record, options?: Partial) {
+ return http({
+ url,
+ data,
+ query,
+ method: 'PUT',
+ header,
+ ...options,
+ })
+}
+
+export function httpDelete(url: string, query?: Record, header?: Record, options?: Partial) {
+ return http({
+ 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
diff --git a/app/app/src/http/interceptor.test.ts b/app/app/src/http/interceptor.test.ts
new file mode 100644
index 0000000..0b19569
--- /dev/null
+++ b/app/app/src/http/interceptor.test.ts
@@ -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`/)
+ })
+})
diff --git a/app/app/src/http/interceptor.ts b/app/app/src/http/interceptor.ts
new file mode 100644
index 0000000..896366a
--- /dev/null
+++ b/app/app/src/http/interceptor.ts
@@ -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)
+ },
+}
diff --git a/app/app/src/http/tools/enum.ts b/app/app/src/http/tools/enum.ts
new file mode 100644
index 0000000..72cfce0
--- /dev/null
+++ b/app/app/src/http/tools/enum.ts
@@ -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,
+ }
+}
diff --git a/app/app/src/http/tools/queryString.ts b/app/app/src/http/tools/queryString.ts
new file mode 100644
index 0000000..edf973e
--- /dev/null
+++ b/app/app/src/http/tools/queryString.ts
@@ -0,0 +1,29 @@
+/**
+ * 将对象序列化为URL查询字符串,用于替代第三方的 qs 库,节省宝贵的体积
+ * 支持基本类型值和数组,不支持嵌套对象
+ * @param obj 要序列化的对象
+ * @returns 序列化后的查询字符串
+ */
+export function stringifyQuery(obj: Record): 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('&')
+}
diff --git a/app/app/src/http/types.ts b/app/app/src/http/types.ts
new file mode 100644
index 0000000..8187822
--- /dev/null
+++ b/app/app/src/http/types.ts
@@ -0,0 +1,44 @@
+/**
+ * 在 uniapp 的 RequestOptions 和 IUniUploadFileOptions 基础上,添加自定义参数
+ */
+export type CustomRequestOptions = UniApp.RequestOptions & {
+ query?: Record
+ /** 出错时是否隐藏错误提示 */
+ hideErrorToast?: boolean
+} & IUniUploadFileOptions // 添加uni.uploadFile参数类型
+
+/** 主要提供给 openapi-ts-request 生成的代码使用 */
+export type CustomRequestOptions_ = Omit
+
+export interface HttpRequestResult {
+ promise: Promise
+ requestTask: UniApp.RequestTask
+}
+
+// 通用响应格式(兼容 msg + message 字段)
+export type IResponse = {
+ 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 {
+ list: T[]
+ total: number
+ page: number
+ pageSize: number
+}
diff --git a/app/app/src/http/vue-query.ts b/app/app/src/http/vue-query.ts
new file mode 100644
index 0000000..69ca80d
--- /dev/null
+++ b/app/app/src/http/vue-query.ts
@@ -0,0 +1,30 @@
+import type { CustomRequestOptions } from '@/http/types'
+import { http } from './http'
+
+/*
+ * openapi-ts-request 工具的 request 跨客户端适配方法
+ */
+export default function request(
+ url: string,
+ options: Omit & {
+ params?: Record
+ headers?: Record
+ },
+) {
+ 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(requestOptions)
+}
diff --git a/app/app/src/layouts/blank.vue b/app/app/src/layouts/blank.vue
new file mode 100644
index 0000000..4d3372d
--- /dev/null
+++ b/app/app/src/layouts/blank.vue
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/app/app/src/layouts/default.vue b/app/app/src/layouts/default.vue
new file mode 100644
index 0000000..c810df1
--- /dev/null
+++ b/app/app/src/layouts/default.vue
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/app/src/locale/README.md b/app/app/src/locale/README.md
new file mode 100644
index 0000000..1e77098
--- /dev/null
+++ b/app/app/src/locale/README.md
@@ -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) 最下面的注意事项
diff --git a/app/app/src/locale/en.json b/app/app/src/locale/en.json
new file mode 100644
index 0000000..f2a758f
--- /dev/null
+++ b/app/app/src/locale/en.json
@@ -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}"
+}
diff --git a/app/app/src/locale/index.ts b/app/app/src/locale/index.ts
new file mode 100644
index 0000000..56e0540
--- /dev/null
+++ b/app/app/src/locale/index.ts
@@ -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
diff --git a/app/app/src/locale/zh-Hans.json b/app/app/src/locale/zh-Hans.json
new file mode 100644
index 0000000..dfb655d
--- /dev/null
+++ b/app/app/src/locale/zh-Hans.json
@@ -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}"
+}
diff --git a/app/app/src/main.ts b/app/app/src/main.ts
new file mode 100644
index 0000000..ef771e1
--- /dev/null
+++ b/app/app/src/main.ts
@@ -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,
+ }
+}
diff --git a/app/app/src/manifest.json b/app/app/src/manifest.json
new file mode 100644
index 0000000..365cb58
--- /dev/null
+++ b/app/app/src/manifest.json
@@ -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": [
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ "",
+ ""
+ ],
+ "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/"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/app/src/pages.json b/app/app/src/pages.json
new file mode 100644
index 0000000..386f33b
--- /dev/null
+++ b/app/app/src/pages.json
@@ -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"
+ }
+ ]
+ }
+}
diff --git a/app/app/src/pages/business/components/BusinessHero.vue b/app/app/src/pages/business/components/BusinessHero.vue
new file mode 100644
index 0000000..800fd3d
--- /dev/null
+++ b/app/app/src/pages/business/components/BusinessHero.vue
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+ 常用业务
+
+
+ 办理套餐续费、加油包订购和卡片自助服务。
+
+
+
+
+
+ 套餐续费
+
+
+ 加油包
+
+
+
+
+
diff --git a/app/app/src/pages/business/components/BusinessSection.vue b/app/app/src/pages/business/components/BusinessSection.vue
new file mode 100644
index 0000000..0c70d84
--- /dev/null
+++ b/app/app/src/pages/business/components/BusinessSection.vue
@@ -0,0 +1,218 @@
+
+
+
+
+ {{ props.section.title }}
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+ {{ visibleRoleTag(item) }}
+
+
+ {{ item.desc }}
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/business/data.test.ts b/app/app/src/pages/business/data.test.ts
new file mode 100644
index 0000000..ad4c030
--- /dev/null
+++ b/app/app/src/pages/business/data.test.ts
@@ -0,0 +1,209 @@
+import fs from 'node:fs'
+import { fileURLToPath, URL } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { businessSections, getUserBusinessSections } from './data'
+
+const businessDir = fileURLToPath(new URL('.', import.meta.url))
+
+const expectedSubViews = [
+ 'diagnosis',
+ 'renew',
+ 'allocate',
+ 'change',
+ 'addon',
+ 'customer',
+ 'customerDetail',
+ 'order',
+ 'warning',
+ 'workOrder',
+ 'workOrderCreate',
+ 'workOrderDetail',
+]
+
+describe('getUserBusinessSections', () => {
+ it('按服务、资源管理和执行记录划分入口且不重复', () => {
+ const sectionItems = (title: string) => businessSections.find(section => section.title === title)?.items.map(item => item.action)
+ expect(sectionItems('服务记录')).toEqual(['order', 'workOrder'])
+ expect(sectionItems('卡片与客户')).toEqual(['cardList', 'customer'])
+ expect(sectionItems('执行记录')).toEqual(['executionLog'])
+ const titles = businessSections.flatMap(section => section.items.map(item => item.title))
+ expect(new Set(titles).size).toBe(titles.length)
+ })
+
+ it('只保留用户侧一级业务入口', () => {
+ const sections = getUserBusinessSections(businessSections)
+ const titles = sections.flatMap(section => section.items.map(item => item.title))
+
+ expect(titles).toContain('套餐续费')
+ expect(titles).not.toContain('余额充值')
+ expect(titles).toContain('重启卡')
+ expect(titles).toContain('停机 / 复机')
+ expect(titles).toContain('加油包')
+ expect(titles).toContain('预警配置')
+ expect(titles).toContain('卡片排查')
+ expect(titles).toContain('我的卡片')
+ expect(titles).toContain('我的订单')
+ expect(titles).toContain('工单服务')
+ expect(titles).not.toContain('进入商城')
+ expect(titles).not.toContain('订单记录')
+ expect(titles).not.toContain('流量池预警(代理商独有)')
+ })
+
+ it('可办理业务都有明确动作', () => {
+ const sections = getUserBusinessSections(businessSections)
+ const actionableItems = sections
+ .flatMap(section => section.items)
+ .filter(item => item.action)
+
+ expect(actionableItems.map(item => [item.title, item.action])).toEqual([
+ ['套餐续费', 'renew'],
+ ['加油包', 'addon'],
+ ['停机 / 复机', 'cardList'],
+ ['重启卡', 'cardList'],
+ ['卡片排查', 'diagnosis'],
+ ['预警配置', 'warning'],
+ ['我的订单', 'order'],
+ ['工单服务', 'workOrder'],
+ ['我的卡片', 'cardList'],
+ ])
+ })
+
+ it('卡片排查完全开放,不受业务权限过滤', () => {
+ const sections = getUserBusinessSections({
+ userId: 18,
+ deptId: 100,
+ userType: 'sys_user',
+ appAccess: {
+ configured: true,
+ permissions: [],
+ features: [],
+ routes: [],
+ menus: [],
+ },
+ } as any)
+ const titles = sections.flatMap(section => section.items.map(item => item.title))
+
+ expect(titles).toContain('卡片排查')
+ expect(titles).toContain('我的订单')
+ })
+
+ it('企业用户显示卡片划拨、套餐变更和客户管理入口', () => {
+ const enterpriseUserTypes = ['sys_user', '']
+ enterpriseUserTypes.forEach((userType, index) => {
+ const sections = getUserBusinessSections({
+ userId: 10 + index,
+ deptId: 100,
+ userType,
+ } as any)
+ const titles = sections.flatMap(section => section.items.map(item => item.title))
+
+ expect(titles).toContain('卡片划拨')
+ expect(titles).toContain('套餐变更')
+ expect(titles).toContain('客户管理')
+ expect(titles).toContain('停机 / 复机')
+ expect(titles).toContain('重启卡')
+ })
+ })
+
+ it('终端用户不显示卡片划拨、套餐变更和客户管理入口', () => {
+ const sections = getUserBusinessSections({
+ userId: 11,
+ deptId: 147,
+ userType: 'app_user',
+ } as any)
+ const titles = sections.flatMap(section => section.items.map(item => item.title))
+
+ expect(titles).not.toContain('卡片划拨')
+ expect(titles).not.toContain('套餐变更')
+ expect(titles).not.toContain('客户管理')
+ expect(titles).toContain('停机 / 复机')
+ expect(titles).toContain('重启卡')
+ expect(titles).toContain('加油包')
+ expect(titles).toContain('预警配置')
+ expect(titles).toContain('卡片排查')
+ expect(titles).toContain('我的卡片')
+ expect(titles).toContain('工单服务')
+ })
+
+ it('配置权限时只展示已授权业务入口', () => {
+ const sections = getUserBusinessSections({
+ userId: 12,
+ deptId: 100,
+ userType: 'sys_user',
+ appAccess: {
+ configured: true,
+ permissions: [],
+ features: ['card:list', 'customer:list'],
+ routes: [],
+ menus: [],
+ },
+ } as any)
+ const titles = sections.flatMap(section => section.items.map(item => item.title))
+
+ expect(titles).toContain('我的卡片')
+ expect(titles).toContain('客户管理')
+ expect(titles).toContain('卡片排查')
+ expect(titles).toContain('我的订单')
+ expect(titles).not.toContain('套餐续费')
+ expect(titles).not.toContain('卡片划拨')
+ expect(titles).not.toContain('套餐变更')
+ })
+
+ it('控制台权限标识直接决定可见业务入口', () => {
+ const sections = getUserBusinessSections({
+ userId: 13,
+ deptId: 100,
+ userType: 'sys_user',
+ appAccess: {
+ configured: true,
+ permissions: ['cpm:iot:tSimCardInfo:list', 'cpm:agent:tAgentEnterprise:list'],
+ features: [],
+ routes: [],
+ menus: [],
+ },
+ } as any)
+ const actions = sections.flatMap(section => section.items.map(item => item.action))
+
+ expect(actions).toContain('cardList')
+ expect(actions).toContain('customer')
+ expect(actions).not.toContain('renew')
+ expect(actions).not.toContain('allocate')
+ expect(actions).not.toContain('change')
+ })
+
+ it('有工单列表或新增权限都展示工单服务入口', () => {
+ const listSections = getUserBusinessSections({
+ userId: 14,
+ deptId: 100,
+ userType: 'sys_user',
+ appAccess: {
+ configured: true,
+ permissions: ['cpm:gen:tWoMain:list'],
+ features: [],
+ routes: [],
+ menus: [],
+ },
+ } as any)
+ const createSections = getUserBusinessSections({
+ userId: 15,
+ deptId: 100,
+ userType: 'sys_user',
+ appAccess: {
+ configured: true,
+ permissions: ['cpm:gen:tWoMain:edit'],
+ features: [],
+ routes: [],
+ menus: [],
+ },
+ } as any)
+
+ expect(listSections.flatMap(section => section.items.map(item => item.title))).toContain('工单服务')
+ expect(createSections.flatMap(section => section.items.map(item => item.title))).toContain('工单服务')
+ })
+
+ it('业务子界面容器文件都存在', () => {
+ expectedSubViews.forEach((viewName) => {
+ expect(fs.existsSync(`${businessDir}views/${viewName}/index.vue`)).toBe(true)
+ })
+ })
+})
diff --git a/app/app/src/pages/business/data.ts b/app/app/src/pages/business/data.ts
new file mode 100644
index 0000000..c05a290
--- /dev/null
+++ b/app/app/src/pages/business/data.ts
@@ -0,0 +1,241 @@
+import type { IUserInfoRes } from '@/api/types/login'
+import type { AppPermissionAction } from '@/utils/permission'
+import { hasAccessCode, hasUserPermission } from '@/utils/permission'
+import { getAccountWorkspace, isTerminalUserInfo } from '@/utils/userIdentity'
+
+export type BusinessTone = 'blue' | 'green' | 'orange' | 'primary' | 'purple' | 'red'
+
+export interface BusinessServiceItem {
+ title: string
+ desc: string
+ tag: string
+ icon: string
+ tone: BusinessTone
+ action?:
+ | 'addon'
+ | 'allocate'
+ | 'balanceRecharge'
+ | 'cardList'
+ | 'change'
+ | 'customer'
+ | 'executionLog'
+ | 'diagnosis'
+ | 'order'
+ | 'renew'
+ | 'warning'
+ | 'workOrder'
+ accessCode?: string
+ agentOnly?: boolean
+ terminalOnly?: boolean
+ terminalHidden?: boolean
+ permissionAction?: AppPermissionAction | AppPermissionAction[]
+}
+
+export interface BusinessServiceSection {
+ title: string
+ desc: string
+ type: 'grid' | 'list'
+ items: BusinessServiceItem[]
+}
+
+/** ==================== 常量 ==================== */
+export const businessSections: BusinessServiceSection[] = [
+ {
+ title: '套餐与流量',
+ desc: '续费、变更与加油包',
+ type: 'grid',
+ items: [
+ {
+ title: '套餐续费',
+ desc: '续约当前套餐,避免停机',
+ tag: '续费',
+ icon: 'i-carbon-renew',
+ tone: 'green',
+ action: 'renew',
+ permissionAction: 'renew',
+ },
+ {
+ title: '加油包',
+ desc: '流量加餐,按需购买',
+ tag: '流量',
+ icon: 'i-carbon-package',
+ tone: 'blue',
+ action: 'addon',
+ permissionAction: 'addon',
+ },
+ {
+ title: '套餐变更',
+ desc: '更换套餐档位',
+ tag: '变更',
+ icon: 'i-carbon-change-catalog',
+ tone: 'primary',
+ action: 'change',
+ agentOnly: true,
+ permissionAction: 'change',
+ },
+ ],
+ },
+ {
+ title: '生命周期操作',
+ desc: '停复机与联网操作',
+ type: 'grid',
+ items: [
+ {
+ title: '停机 / 复机',
+ desc: '进入卡片选择暂停或恢复服务',
+ tag: '生命周期',
+ icon: 'i-carbon-pause-outline',
+ tone: 'orange',
+ action: 'cardList',
+ permissionAction: ['stop', 'start'],
+ },
+ {
+ title: '重启卡',
+ desc: '重新建立网络会话',
+ tag: '重启',
+ icon: 'i-carbon-restart',
+ tone: 'primary',
+ action: 'cardList',
+ permissionAction: 'restart',
+ },
+ ],
+ },
+ {
+ title: '管理与预警',
+ desc: '状态排查、预警与分配',
+ type: 'grid',
+ items: [
+ {
+ title: '卡片排查',
+ desc: '状态、会话和流量异常',
+ tag: '排查',
+ icon: 'i-carbon-stethoscope',
+ tone: 'green',
+ action: 'diagnosis',
+ },
+ {
+ title: '预警配置',
+ desc: '流量阈值与站内消息提醒',
+ tag: '预警',
+ icon: 'i-carbon-warning-alt',
+ tone: 'orange',
+ action: 'warning',
+ permissionAction: 'warning',
+ },
+ {
+ title: '卡片划拨',
+ desc: '分配给下游客户/子账号',
+ tag: '划拨',
+ icon: 'i-carbon-share-knowledge',
+ tone: 'blue',
+ action: 'allocate',
+ agentOnly: true,
+ permissionAction: 'allocate',
+ },
+ ],
+ },
+ {
+ title: '服务记录',
+ desc: '订单查询与问题反馈',
+ type: 'grid',
+ items: [
+ {
+ title: '我的订单',
+ desc: '续费/加油包办理记录',
+ tag: '订单',
+ icon: 'i-carbon-receipt',
+ tone: 'blue',
+ action: 'order',
+ },
+ {
+ title: '工单服务',
+ desc: '反馈问题和查看进度',
+ tag: '工单',
+ icon: 'i-carbon-chat',
+ tone: 'orange',
+ action: 'workOrder',
+ permissionAction: ['workOrder', 'workOrderCreate'],
+ },
+ ],
+ },
+ {
+ title: '卡片与客户',
+ desc: '卡片信息与客户管理',
+ type: 'list',
+ items: [
+ {
+ title: '我的卡片',
+ desc: '查看卡信息、套餐和流量',
+ tag: '进入',
+ icon: 'i-carbon-sim-card',
+ tone: 'primary',
+ action: 'cardList',
+ },
+ {
+ title: '客户管理',
+ desc: '授权范围内的客户与详情',
+ tag: '客户',
+ icon: 'i-carbon-user-multiple',
+ tone: 'green',
+ action: 'customer',
+ agentOnly: true,
+ permissionAction: 'customer',
+ },
+ ],
+ },
+ {
+ title: '执行记录',
+ desc: '批次进度与执行结果',
+ type: 'list',
+ items: [
+ {
+ title: '批次执行日志',
+ desc: '查看批次进度与执行结果',
+ tag: '日志',
+ icon: 'i-carbon-document',
+ tone: 'blue',
+ action: 'executionLog',
+ accessCode: 'cpm:common:batchExecutionLog:list',
+ },
+ ],
+ },
+]
+
+/** ==================== 方法 ==================== */
+export function getUserBusinessSections(
+ userInfoOrSections?: IUserInfoRes | BusinessServiceSection[],
+ sections: BusinessServiceSection[] = businessSections,
+) {
+ const userInfo = Array.isArray(userInfoOrSections) ? undefined : userInfoOrSections
+ const sourceSections = Array.isArray(userInfoOrSections) ? userInfoOrSections : sections
+ const workspace = getAccountWorkspace(userInfo)
+ const isAgentUser = Boolean(userInfo) && workspace !== 'terminal'
+ const isTerminalUser = !userInfo || isTerminalUserInfo(userInfo)
+
+ return sourceSections
+ .map(section => ({
+ ...section,
+ items: section.items.filter((item) => {
+ if (item.accessCode && !hasAccessCode(userInfo?.accessCodes, item.accessCode))
+ return false
+ if (item.agentOnly && !isAgentUser)
+ return false
+ if (item.terminalOnly && !isTerminalUser)
+ return false
+ if (item.terminalHidden && isTerminalUser)
+ return false
+ if (userInfo && item.permissionAction && !hasBusinessItemPermission(userInfo, item.permissionAction))
+ return false
+ return true
+ }),
+ }))
+ .filter(section => section.items.length > 0)
+}
+
+function hasBusinessItemPermission(
+ userInfo: IUserInfoRes,
+ action: AppPermissionAction | AppPermissionAction[],
+) {
+ const actions = Array.isArray(action) ? action : [action]
+ return actions.some(item => hasUserPermission(userInfo, item))
+}
diff --git a/app/app/src/pages/business/index.test.ts b/app/app/src/pages/business/index.test.ts
new file mode 100644
index 0000000..ca424a5
--- /dev/null
+++ b/app/app/src/pages/business/index.test.ts
@@ -0,0 +1,125 @@
+import type { IUserInfoRes } from '@/api/types/login'
+import { readFileSync } from 'node:fs'
+import { createContext, runInContext } from 'node:vm'
+import { ModuleKind, transpileModule } from 'typescript'
+import { describe, expect, it, vi } from 'vitest'
+import RouterPath from '@/constant/RouterPath'
+import * as release from '@/constant/release'
+import { customTabbarList } from '@/tabbar/config'
+import * as permission from '@/utils/permission'
+import * as data from './data'
+
+function createHarness(userInfo: Partial) {
+ const navigateTo = vi.fn((options: { complete?: () => void }) => options.complete?.())
+ const switchTab = vi.fn((options: { complete?: () => void }) => options.complete?.())
+ const fetchUserInfo = vi.fn(async () => userInfo)
+ const tokenStore = { hasLogin: true, updateNowTime: () => tokenStore }
+ const context = createContext({
+ exports: {},
+ require: (name: string) => {
+ if (name === '@/constant/RouterPath')
+ return { default: RouterPath }
+ if (name === '@/constant/release')
+ return release
+ if (name === '@/utils/permission')
+ return permission
+ if (name === '@/store/user')
+ return { useUserStore: () => ({ userInfo, fetchUserInfo }) }
+ if (name === '@/store/token')
+ return { useTokenStore: () => tokenStore }
+ if (name === './data')
+ return data
+ if (name === './components/BusinessSection.vue')
+ return {}
+ throw new Error(`Unexpected import: ${name}`)
+ },
+ ref: (value: unknown) => ({ value }),
+ computed: (getter: () => unknown) => ({ get value() { return getter() } }),
+ definePage: () => undefined,
+ onShow: () => undefined,
+ uni: { navigateTo, switchTab, showToast: vi.fn() },
+ Promise,
+ })
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+ const script = source.match(/
+
+
+
+
+
+
+
+
+
+ 不确定办哪项?先查卡状态
+ 按卡片状态、套餐、流量和会话信息整理处理方向
+
+ 排查建议仅供辅助判断,以实际业务处理结果为准
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/addon/index.vue b/app/app/src/pages/business/views/addon/index.vue
new file mode 100644
index 0000000..0da752c
--- /dev/null
+++ b/app/app/src/pages/business/views/addon/index.vue
@@ -0,0 +1,219 @@
+
+
+
+
+
+
+
+ 当前账号不能订购加油包。
+
+
+
+
+
+
+
+
+
+
+
+ 加油包
+ 按需补充流量,选定后直接进入支付确认
+
+
+
+ 订购
+
+
+
+
+
+ 当前卡片
+ {{ cardValueText }}
+
+
+ 当前套餐
+ {{ packageText }}
+
+
+ 剩余流量
+ {{ flowText }}
+
+
+ 卡片状态
+ {{ statusText }}
+
+
+
+
+
+
+
+
+ 选择加油包
+ 按流量档位单选,价格与生效时间由后端统一控制
+
+
+ {{ selectedOption.name }}
+
+
+
+
+
+
+
+ {{ item.name }}
+
+ {{ item.badge }}
+
+
+
+ {{ item.flowText }} · {{ item.desc }}
+
+
+ {{ item.price }}
+
+
+
+
+
+
+
+
+
+ 已选加油包
+ {{ selectedOption.name }}
+
+
+ 付款方式
+ 微信支付
+
+
+ 生效时间
+ 订购成功后立即生效
+
+
+ 到期时间
+ {{ expireText }}
+
+
+
+
+
+
+
+
+
+
+ {{ cardValueText }}
+ 应付 {{ selectedAmountText }}
+
+
+ 立即订购 {{ selectedAmountText }}
+
+
+
+
diff --git a/app/app/src/pages/business/views/allocate/index.vue b/app/app/src/pages/business/views/allocate/index.vue
new file mode 100644
index 0000000..aec2844
--- /dev/null
+++ b/app/app/src/pages/business/views/allocate/index.vue
@@ -0,0 +1,848 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isFixedCardMode ? '单卡划拨' : '批量卡片划拨' }}
+ 本企业卡片 → 启用的直属下级客户
+
+
+
+ {{ isFixedCardMode ? '1 张卡' : '最多 100 张' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1. 选择目标客户
+ 划拨完成后,卡片归属转至目标客户
+
+
+
+
+
+
+
+
+ 2. 选择卡片
+ {{ isFixedCardMode ? '已锁定单卡入口,仅办理当前卡片' : '跨搜索保留选择,单次最多 100 张' }}
+
+
+ {{ selectedCardValues.length }}/{{ isFixedCardMode ? 1 : 100 }}
+
+
+
+ 当前入口卡片(不可切换)
+ {{ initialCardValue }}
+
+
+
+ 已加载 {{ cards.length }}/{{ cardTotal }} 张
+
+
+ 刷新
+
+
+ {{ allVisibleSelected ? '取消全选' : '全选已加载' }}
+
+
+
+
+ 已选 {{ selectedCardValues.length }} 张
+
+ 查看已选
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayCardValue(item) }}
+
+
+ {{ displayPackageName(item) }}
+
+
+ {{ formatFlowPair(item) }} · 到期 {{ formatDateTime(item.flowPkgEndAt) }}
+
+
+ 归属客户:{{ ownerDeptText(item) }}
+
+
+ 终端绑定:{{ item.terminalName }}
+
+
+
+
+
+
+
+
+
+ 其他搜索结果中的 {{ selectedCardValues.length }} 张已选卡仍保留
+
+
+
+
+
+ 加载更多卡片
+
+
+
+ 停机、销户或套餐过期卡不可划拨。
+
+
+
+
+
+
+ 已选 {{ selectedCardValues.length }} 张卡 跨搜索保留,移除不会改变卡片归属
+
+ 清空
+
+
+
+
+
+
+ 移除
+
+
+
+
+
+
+ 完成
+
+
+
+
+
+
+ 选择目标客户
+ 关闭
+
+
+
+
+ 启用直属下级 · {{ targets.length }}/{{ targetTotal }} 个
+ 刷新
+
+
+
+
+
+ {{ targetError }}
+ 重试加载
+
+
+
+
+
+ 已选择
+
+
+ {{ targetScopeName(item) }}
+
+
+
+
+
+ 加载更多目标客户
+
+
+
+
+
+
+
+
+
+
+ {{ submitting ? '正在提交划拨任务' : submitHint }}
+ {{ taskPending && !submitting ? resultPresentation.title : `已选 ${selectedCardValues.length} 张 · ${targetLabel(selectedTarget)}` }}
+
+
+
+ 核查后重新办理
+
+
+ {{ result?.status === 'prepared' ? '提交原批次' : '刷新任务结果' }}
+
+
+ 确认划拨{{ selectedCardValues.length ? ` ${selectedCardValues.length} 张` : '' }}
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/allocate/selection.test.ts b/app/app/src/pages/business/views/allocate/selection.test.ts
new file mode 100644
index 0000000..51921c8
--- /dev/null
+++ b/app/app/src/pages/business/views/allocate/selection.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import { allocateDetailStatus, allocateResultStatus, allocateSelectionFingerprint, allocateStatusPresentation, allocateTaskSnapshot, mergeAllocateSelection } from './selection'
+
+describe('划拨选卡', () => {
+ it('搜索和分页不会清除其他页已选卡', () => {
+ expect(mergeAllocateSelection(['A', 'B'], ['B', 'C'], ['C'])).toEqual(['A', 'C'])
+ })
+ it('重复选择只保留一份', () => {
+ expect(mergeAllocateSelection(['A'], ['B'], ['B', 'B'])).toEqual(['A', 'B'])
+ })
+ it('相同卡集合保持相同快照,目标变更使校验失效', () => {
+ expect(allocateSelectionFingerprint(['A', 'B'], 1)).toBe(allocateSelectionFingerprint(['B', 'A'], 1))
+ expect(allocateSelectionFingerprint(['A'], 1)).not.toBe(allocateSelectionFingerprint(['A'], 2))
+ })
+})
+
+describe('划拨执行结果', () => {
+ it('等待和处理中不会被误报为成功或失败', () => {
+ expect(allocateResultStatus(0)).toBe('pending')
+ expect(allocateResultStatus(1)).toBe('pending')
+ expect(allocateResultStatus(2)).toBe('processing')
+ expect(allocateResultStatus(999)).toBe('pending')
+ })
+ it('保留异常部分成功状态,不误报为整批失败', () => {
+ expect(allocateResultStatus(3)).toBe('success')
+ expect(allocateResultStatus(4)).toBe('failed')
+ expect(allocateResultStatus(5)).toBe('partial_success')
+ })
+})
+
+describe('划拨恢复快照', () => {
+ const item = { requestId: 'request', batchNo: '', batchType: 3, status: 'pending', quantity: 1, target: { deptId: 2, deptName: '客户' }, cards: [], details: [] }
+ const request = { requestId: 'request', cardValues: ['A'], targetDeptId: 2, executionMode: 2 }
+ it('受理前保留原请求以便恢复重试', () => {
+ const saved = allocateTaskSnapshot(item, request)
+ expect(saved.pendingRequest).toEqual(request)
+ expect(saved.cards).toEqual([])
+ expect(saved.details).toEqual([])
+ })
+ it('受理后和明确拒绝后清理原卡号参数', () => {
+ expect(allocateTaskSnapshot({ ...item, batchNo: 'ALC' }, request).pendingRequest).toBeUndefined()
+ expect(allocateTaskSnapshot({ ...item, status: 'failed' }, request).pendingRequest).toBeUndefined()
+ })
+})
+
+describe('划拨页面状态展示', () => {
+ it('区分受理不确定和后台待处理', () => {
+ expect(allocateStatusPresentation('pending', false).title).toBe('提交结果待确认')
+ expect(allocateStatusPresentation('pending', true).title).toBe('等待后台处理')
+ })
+ it('区分校验拒绝和执行失败,异常部分成功提示核查', () => {
+ expect(allocateStatusPresentation('failed', false).title).toBe('校验未通过')
+ expect(allocateStatusPresentation('failed', true).title).toBe('划拨失败')
+ expect(allocateStatusPresentation('partial_success').title).toBe('结果待核查')
+ expect(allocateStatusPresentation('success').type).toBe('success')
+ expect(allocateStatusPresentation().title).toBe('待提交')
+ })
+ it('子任务校验通过不能误报为划拨完成', () => {
+ expect(allocateDetailStatus(2, 1)).toBe('校验通过 · 待整批完成')
+ expect(allocateDetailStatus(2, 2)).toBe('校验通过 · 待整批完成')
+ expect(allocateDetailStatus(2, 4)).toBe('校验通过 · 整批未完成')
+ expect(allocateDetailStatus(2, 3)).toBe('已划拨')
+ expect(allocateDetailStatus(3, 4)).toBe('校验失败')
+ expect(allocateDetailStatus(999, 2)).toBe('状态待确认')
+ })
+})
+
+describe('直接异步提交不确定状态', () => {
+ it('响应丢失不保存可重建请求,不声称未受理', () => {
+ const item = { batchNo: '', batchType: 3, status: 'unknown', quantity: 1, target: { deptId: 2, deptName: '客户' }, cards: [], details: [] }
+ expect(allocateTaskSnapshot(item, { cardValues: ['A'], targetDeptId: 2 }).pendingRequest).toBeUndefined()
+ expect(allocateStatusPresentation('unknown', false).description).toContain('后台可能已创建任务')
+ })
+})
diff --git a/app/app/src/pages/business/views/allocate/selection.ts b/app/app/src/pages/business/views/allocate/selection.ts
new file mode 100644
index 0000000..33d402c
--- /dev/null
+++ b/app/app/src/pages/business/views/allocate/selection.ts
@@ -0,0 +1,22 @@
+import type { AllocateBatchRes, AllocateBatchSubmitParams } from '@/api/types/card'
+
+export function mergeAllocateSelection(selected: string[], visible: string[], checked: string[]) {
+ const visibleSet = new Set(visible)
+ return Array.from(new Set([...selected.filter(value => !visibleSet.has(value)), ...checked]))
+}
+
+export function allocateSelectionFingerprint(values: string[], targetId?: number) {
+ return JSON.stringify([targetId || 0, Array.from(new Set(values)).sort()])
+}
+
+export function allocateTaskSnapshot(item: AllocateBatchRes, request?: AllocateBatchSubmitParams) {
+ return {
+ ...item,
+ cards: [],
+ details: [],
+ target: { deptId: item.target.deptId, deptName: item.target.deptName },
+ pendingRequest: item.batchNo || !['pending', 'processing'].includes(item.status) ? undefined : request,
+ }
+}
+
+export { allocateDetailStatus, allocateResultStatus, allocateStatusPresentation } from '@/utils/allocateStatus'
diff --git a/app/app/src/pages/business/views/allocate/submit.test.ts b/app/app/src/pages/business/views/allocate/submit.test.ts
new file mode 100644
index 0000000..93b9ec8
--- /dev/null
+++ b/app/app/src/pages/business/views/allocate/submit.test.ts
@@ -0,0 +1,81 @@
+import { readFileSync } from 'node:fs'
+import { createContext, runInContext } from 'node:vm'
+import { ModuleKind, transpileModule } from 'typescript'
+import { describe, expect, it, vi } from 'vitest'
+import * as selection from './selection'
+
+function harness() {
+ const submit = vi.fn()
+ const summary = vi.fn()
+ const toast = vi.fn()
+ const removeStorage = vi.fn()
+ const userInfo = { userId: 12, tenantId: 'A', deptId: 10 }
+ const context = createContext({
+ exports: {},
+ require: (name: string) => {
+ if (name === '@/api/card')
+ return { allocateBatchSubmit: submit, getAllocateTargets: vi.fn(), getCardList: vi.fn() }
+ if (name === '@/api/executionLog')
+ return { allocateSummary: summary }
+ if (name === './selection')
+ return selection
+ if (name === '@/pages/card/data')
+ return {}
+ if (name === '@/store/user')
+ return { useUserStore: () => ({ userInfo, fetchUserInfo: vi.fn(async () => userInfo) }) }
+ if (name === '@/utils/permission')
+ return { hasUserPermission: () => true }
+ if (name === '@/utils/userIdentity')
+ return { getAccountWorkspace: () => 'agent' }
+ throw new Error(name)
+ },
+ ref: (value: unknown) => ({ value }),
+ computed: (getter: (() => unknown) | { get: () => unknown }) => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
+ definePage: () => undefined,
+ watch: () => undefined,
+ onShow: () => undefined,
+ onHide: () => undefined,
+ onUnload: () => undefined,
+ onMounted: () => undefined,
+ onLoad: () => undefined,
+ setTimeout: vi.fn(),
+ uni: {
+ showModal: (options: { success: (value: { confirm: boolean }) => void }) => options.success({ confirm: true }),
+ showToast: toast,
+ setStorageSync: vi.fn(),
+ removeStorageSync: removeStorage,
+ getStorageSync: vi.fn(),
+ },
+ })
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+ const script = source.match(/
+
+
+
+
+
+
+
+ 账户资金
+ 当前余额
+
+
+
+ {{ balanceText }}
+
+
+
+
+
+
+ 充值入口暂未开放
+ 历史充值订单仍可在我的订单中查看,套餐续费等业务继续使用余额扣款。
+
+
+
+
+
+ 可继续办理
+
+
+ 套餐续费、套餐变更等业务仍会使用账户余额扣款;余额不足时请联系平台处理。
+
+
+
+ 我的订单
+
+
+ 套餐续费
+
+
+
+
+
+ 返回业务
+
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/cardRenew/index.vue b/app/app/src/pages/business/views/cardRenew/index.vue
new file mode 100644
index 0000000..05b7576
--- /dev/null
+++ b/app/app/src/pages/business/views/cardRenew/index.vue
@@ -0,0 +1,489 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 套餐续费
+
+ {{ prepare ? `${displayCard()} · ${packageName()}` : '选择可续费套餐,到期时间按后端规则顺延' }}
+
+
+
+
+ 单卡办理
+
+
+
+
+
+ 当前到期
+ {{ formatDateTime(prepare.card.flowPkgEndAt) }}
+
+
+ 剩余流量
+ {{ formatFlowPair(prepare.card) }}
+
+
+ 当前套餐
+ {{ currentPackageName() }}
+
+
+ 续费单年价
+ {{ formatMoney(prepare.unitPrice) }}
+
+
+
+
+
+
+
+
+
+ 查询续费档位
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+ 1
+
+ 选择续费套餐
+
+
+
+
+
+ {{ item.packageName || item.package?.packageName || renewPackageCode(item) || '-' }}
+
+ 已选
+
+
+
+ {{ item.priceText || formatMoney(item.renewUnitPrice || item.unitPrice) }} · {{ item.options?.length || item.renewableDurationOptions?.length || 0 }} 个可续费年限
+
+
+ {{ item.priceText || formatMoney(item.renewUnitPrice || item.unitPrice) }}
+
+
+
+
+
+
+ 当前套餐暂无可续费套餐
+
+
+
+
+
+ 2
+
+ 选择续费年限
+
+
+
+
+
+ {{ item.label }}
+
+ 推荐
+
+
+
+ {{ optionTag(item, index) }} · 到期 {{ formatDateTime(item.expireAfter) }}
+
+
+ {{ item.priceText || formatMoney(item.totalAmount) }}
+
+
+
+
+
+
+ 当前续费套餐暂无可续费年限
+
+
+
+
+
+
+ 确认 ICCID
+ {{ prepare.card.iccid || '-' }}
+
+
+ 当前套餐
+ {{ currentPackageName() }}
+
+
+ 续费套餐
+ {{ packageName() }}
+
+
+ 续费年限
+ {{ selectedOption?.label || '-' }}
+
+
+ 续费后到期
+ {{ formatDateTime(selectedOption?.expireAfter) }}
+
+
+ 应付金额
+ {{ amountText }}
+
+
+
+
+
+
+
+ 办理结果
+
+ 已支付
+
+
+
+ 订单号
+ {{ order.orderSn || '-' }}
+
+
+ 续费批次
+ {{ order.batchNo || '-' }}
+
+
+ 订单金额
+ {{ order.priceText || formatMoney(order.totalAmount) }}
+
+
+
+
+
+
+
+
+
+ {{ displayCard() }}
+ 应付 {{ amountText }}
+
+
+ 确认续费 {{ amountText }}
+
+
+
+
diff --git a/app/app/src/pages/business/views/change/index.vue b/app/app/src/pages/business/views/change/index.vue
new file mode 100644
index 0000000..b8e4799
--- /dev/null
+++ b/app/app/src/pages/business/views/change/index.vue
@@ -0,0 +1,645 @@
+
+
+
+
+
+
+
+
+ 当前账号不能使用套餐变更。
+
+
+
+
+
+
+
+
+
+
+
+ 套餐变更
+ 选择同一当前套餐的卡,校验后统一变更目标套餐
+
+
+
+ 批量
+
+
+
+
+ 已选卡片
+ {{ selectedCardValues.length }} 张
+
+
+ 当前套餐
+ {{ prepare?.currentPackage?.packageName || '待校验' }}
+
+
+ 目标套餐
+ {{ selectedTarget?.package?.packageName || '待选择' }}
+
+
+ 应付金额
+ {{ selectedOption ? amountText : '待计算' }}
+
+
+
+
+
+
+
+
+
+
+
+ 刷新
+
+
+
+
+
+ 全选
+
+
+
+
+
+ 清空
+
+
+
+
+
+
+
+
+ 当前可见卡
+ 同一批次必须是同客户、同当前套餐的卡
+
+
+ 已选 {{ selectedCardValues.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayCardValue(item) }}
+
+
+ {{ displayPackageName(item) }}
+
+
+ {{ formatFlowPair(item) }} · 到期 {{ formatDateTime(item.flowPkgEndAt) }}
+
+
+
+
+
+
+
+
+ 暂无可变更套餐的卡片
+
+
+
+
+
+
+ 校验结果
+
+ 已校验
+
+
+
+ 预处理批次
+ {{ prepare.batchNo || '-' }}
+
+
+ 当前套餐
+ {{ prepare.currentPackage?.packageName || '-' }}
+
+
+ 校验卡数
+ {{ prepare.quantity || 0 }} 张
+
+
+ 生效规则
+ {{ prepare.effectiveRule || '-' }}
+
+
+ 目标套餐
+ {{ selectedTarget.package?.packageName || '-' }}
+
+
+ 套餐时长
+ {{ selectedOption.label || '-' }}
+
+
+
+
+
+
+
+ 办理结果
+
+ 已支付
+
+
+
+ 订单号
+ {{ result.orderSn || '-' }}
+
+
+ 变更批次
+ {{ result.batchNo || '-' }}
+
+
+ 订单金额
+ {{ result.priceText || formatMoney(result.totalAmount) }}
+
+
+
+
+
+
+
+
+
+ 选择目标套餐
+
+
+ {{ prepare?.quantity || selectedCardValues.length }} 张卡统一变更
+
+
+
+
+
+
+
+
+ {{ target.package?.packageName || target.package?.packageCode || '-' }}
+
+
+ {{ target.package?.flowText || '流量按套餐配置' }} · {{ target.priceText }}
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+ {{ option.priceText || formatMoney(option.totalAmount) }}
+
+
+ {{ packageOptionTag(option, optionIndex) }}
+
+
+
+
+
+
+ 当前套餐暂无可变更目标套餐
+
+
+
+
+
+ 取消
+
+
+ 确认变更
+
+
+
+
+
+
+
+
+
+
+
+ 已选 {{ selectedCardValues.length }} 张
+ 应付 {{ amountText }}
+ 先校验套餐
+
+
+
+ 校验套餐
+
+
+ 确认变更 {{ selectedOption ? amountText : '' }}
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/customer/index.test.ts b/app/app/src/pages/business/views/customer/index.test.ts
new file mode 100644
index 0000000..7d077cf
--- /dev/null
+++ b/app/app/src/pages/business/views/customer/index.test.ts
@@ -0,0 +1,117 @@
+import type { CustomerListRes } from '@/api/types/customer'
+import { readFileSync } from 'node:fs'
+import { createContext, runInContext } from 'node:vm'
+import { ModuleKind, transpileModule } from 'typescript'
+import { describe, expect, it, vi } from 'vitest'
+
+function createHarness() {
+ const requests = vi.fn<(params: Record) => Promise>()
+ const unmountCallbacks: Array<() => void> = []
+ const features = { allowed: true }
+ const context = createContext({
+ exports: {},
+ require: (name: string) => {
+ if (name === '../api/customer')
+ return { getCustomerList: requests }
+ if (name === '@/store/user')
+ return { useUserStore: () => ({ userInfo: {}, fetchUserInfo: async () => undefined }) }
+ if (name === '@/utils/permission')
+ return { hasAnyFeature: () => features.allowed }
+ if (name === '@/utils/userIdentity')
+ return { getAccountWorkspace: () => 'enterprise', getAccountWorkspaceLabel: () => '企业' }
+ if (name === '@/constant/RouterPath')
+ return { default: { BusinessViewsCustomerDetail: '/customer/detail' } }
+ throw new Error(`Unexpected import: ${name}`)
+ },
+ ref: (value: unknown) => ({ value }),
+ computed: (getter: () => unknown) => ({ get value() { return getter() } }),
+ definePage: () => undefined,
+ onMounted: () => undefined,
+ onUnmounted: (callback: () => void) => unmountCallbacks.push(callback),
+ uni: { showToast: vi.fn(), navigateTo: vi.fn() },
+ })
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+ const script = source.match(/
+
+
+
+
+
+
+
+
+ {{ workspaceLabel }}工作台
+
+
+ 客户管理
+
+
+ {{ noPermission ? '当前账号暂无客户管理权限' : summaryText }}
+
+
+
+ 刷新
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.cardCount || 0 }} 张卡
+
+
+
+ {{ item.realNameStatus }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 加载更多
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/customerDetail/data.test.ts b/app/app/src/pages/business/views/customerDetail/data.test.ts
new file mode 100644
index 0000000..3942a83
--- /dev/null
+++ b/app/app/src/pages/business/views/customerDetail/data.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'node:fs'
+import { getCustomerCardStatusItems } from './data'
+
+describe('客户全部卡片状态', () => {
+ it('客户详情不展示编号、套餐或正常使用数量', () => {
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+ expect(source).not.toContain('客户编码')
+ expect(source).not.toContain('customerCode')
+ expect(source).not.toContain('profile.deptKey')
+ expect(source).not.toContain('normalCardCount')
+ expect(source).not.toContain('套餐')
+ expect(source).not.toContain('packageStatus')
+ expect(source).not.toContain('正常使用')
+ })
+
+ it('全部八张已激活卡均展示,不按套餐过滤', () => {
+ const items = getCustomerCardStatusItems([{ status: 2, count: 8 }])
+ expect(items.find(item => item.status === 2)?.count).toBe(8)
+ expect(items.find(item => item.status === 4)?.count).toBe(0)
+ expect(items.reduce((total, item) => total + item.count, 0)).toBe(8)
+ })
+
+ it('展示全部标准状态,不把非正常卡遗漏', () => {
+ const items = getCustomerCardStatusItems([{ status: 2, count: 3 }, { status: 4, count: 5 }, { status: 9, count: 2 }])
+ expect(items.map(item => item.label)).toEqual(['未知', '待激活', '已激活', '停机', '可测试', '库存', '预销户', '已销户'])
+ expect(items.reduce((total, item) => total + item.count, 0)).toBe(10)
+ expect(items.find(item => item.status === 4)?.count).toBe(5)
+ })
+
+ it('保留非标准状态,合并同状态数据', () => {
+ const items = getCustomerCardStatusItems([{ status: 88, count: 2 }, { status: 88, count: 3 }])
+ expect(items.find(item => item.status === 88)).toEqual({ status: 88, label: '未知状态 88', count: 5 })
+ })
+
+ it('区分未返回统计和零持卡', () => {
+ expect(getCustomerCardStatusItems()).toEqual([])
+ expect(getCustomerCardStatusItems([])).toHaveLength(8)
+ expect(getCustomerCardStatusItems([]).every(item => item.count === 0)).toBe(true)
+ })
+})
diff --git a/app/app/src/pages/business/views/customerDetail/data.ts b/app/app/src/pages/business/views/customerDetail/data.ts
new file mode 100644
index 0000000..0703d72
--- /dev/null
+++ b/app/app/src/pages/business/views/customerDetail/data.ts
@@ -0,0 +1,16 @@
+import type { AppCustomer } from '@/api/types/customer'
+import { bossCardStatusTextMap } from '@/pages/card/data'
+
+export function getCustomerCardStatusItems(counts?: AppCustomer['cardStatusCounts']) {
+ if (!counts)
+ return []
+ const totals = new Map()
+ for (const item of counts)
+ totals.set(item.status, (totals.get(item.status) || 0) + item.count)
+ const statuses = new Set([...Object.keys(bossCardStatusTextMap).map(Number), ...totals.keys()])
+ return [...statuses].sort((first, second) => first - second).map(status => ({
+ status,
+ label: bossCardStatusTextMap[status] || `未知状态 ${status}`,
+ count: totals.get(status) || 0,
+ }))
+}
diff --git a/app/app/src/pages/business/views/customerDetail/index.vue b/app/app/src/pages/business/views/customerDetail/index.vue
new file mode 100644
index 0000000..bf3324e
--- /dev/null
+++ b/app/app/src/pages/business/views/customerDetail/index.vue
@@ -0,0 +1,259 @@
+
+
+
+
+
+
+
+
+
+ {{ titleText }}
+
+
+ {{ customer?.customerTypeName || '客户资料' }}
+
+
+
+
+
+
+
+ 当前账号不能查看客户详情。
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+
+ {{ customer.cardCount || 0 }}
+ 张卡
+
+ 持卡总数
+
+
+
+
+ 运营商卡状态
+
+
+
+ {{ item.count }}
+ {{ item.label }}
+
+
+ 暂无状态统计,请刷新后重试
+ 仅统计该客户直接持有的卡片,不含下级客户。
+
+
+
+
+
+
+ 基础信息
+
+
+
+
+
+
+
+
+
+
+ 企业实名资料
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/diagnosis/DiagnosisFlowChart.test.ts b/app/app/src/pages/business/views/diagnosis/DiagnosisFlowChart.test.ts
new file mode 100644
index 0000000..23b54c0
--- /dev/null
+++ b/app/app/src/pages/business/views/diagnosis/DiagnosisFlowChart.test.ts
@@ -0,0 +1,52 @@
+import { readFileSync } from 'node:fs'
+import { createContext, runInContext } from 'node:vm'
+import { transpileModule } from 'typescript'
+import { describe, expect, it, vi } from 'vitest'
+
+async function drawPoints(points: Array<{ x: number, y: number, available: boolean }>) {
+ const methods = ['clearRect', 'setStrokeStyle', 'setLineWidth', 'beginPath', 'moveTo', 'lineTo', 'stroke', 'setLineCap', 'setLineJoin', 'setFillStyle', 'arc', 'fill', 'draw']
+ const canvas = Object.fromEntries(methods.map(method => [method, vi.fn()]))
+ const query = {
+ in: () => query,
+ select: () => query,
+ boundingClientRect: (callback: (rect: { width: number, height: number }) => void) => {
+ callback({ width: 600, height: 190 })
+ return query
+ },
+ exec: vi.fn(),
+ }
+ const context = createContext({
+ defineProps: () => ({ points }),
+ getCurrentInstance: () => ({ proxy: {} }),
+ nextTick: async () => undefined,
+ onMounted: vi.fn(),
+ onUnmounted: vi.fn(),
+ watch: vi.fn(),
+ uni: { createSelectorQuery: () => query, createCanvasContext: () => canvas },
+ })
+ const source = readFileSync(new URL('./DiagnosisFlowChart.vue', import.meta.url), 'utf8')
+ const script = source.split('')[0]
+ if (!script)
+ throw new Error('missing chart script')
+ runInContext(transpileModule(script, {}).outputText, context)
+ await runInContext('draw()', context)
+ return canvas
+}
+
+describe('小程序流量图绘制', () => {
+ it('只有一个采集点也绘制圆点并提交画布', async () => {
+ const ctx = await drawPoints([{ x: 300, y: 80, available: true }])
+ expect(ctx.arc).toHaveBeenCalledWith(300, 80, 3, 0, Math.PI * 2)
+ expect(ctx.draw).toHaveBeenCalledOnce()
+ })
+ it('未采集周期不画点且断开折线', async () => {
+ const ctx = await drawPoints([
+ { x: 20, y: 80, available: true },
+ { x: 300, y: 156, available: false },
+ { x: 580, y: 40, available: true },
+ ])
+ expect(ctx.arc).toHaveBeenCalledTimes(2)
+ expect(ctx.moveTo).toHaveBeenCalledWith(580, 40)
+ expect(ctx.lineTo).not.toHaveBeenCalledWith(580, 40)
+ })
+})
diff --git a/app/app/src/pages/business/views/diagnosis/DiagnosisFlowChart.vue b/app/app/src/pages/business/views/diagnosis/DiagnosisFlowChart.vue
new file mode 100644
index 0000000..77b604e
--- /dev/null
+++ b/app/app/src/pages/business/views/diagnosis/DiagnosisFlowChart.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/diagnosis/display.test.ts b/app/app/src/pages/business/views/diagnosis/display.test.ts
new file mode 100644
index 0000000..c3ab73e
--- /dev/null
+++ b/app/app/src/pages/business/views/diagnosis/display.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from 'vitest'
+import {
+ diagnosisDataServiceDisplay,
+ diagnosisFlowRetryHint,
+ diagnosisOnlineStatusDisplay,
+ diagnosisSessionStatusDisplay,
+ isDiagnosisNoDataText,
+ isDiagnosisUnsupportedModule,
+ pickDiagnosisFlowValue,
+ pickDiagnosisRemainingFlowValue,
+ resolveDiagnosisOnlineStatus,
+ shouldHideDiagnosisModule,
+ shouldHideDiagnosisText,
+} from './display'
+
+describe('在线状态展示', () => {
+ it('未知、缺失或非法状态统一显示横线', () => {
+ for (const status of ['2', 'UNKNOWN', '', 'unexpected', 'constructor', 'toString', '__proto__']) {
+ expect(diagnosisOnlineStatusDisplay(status)).toBe('-')
+ }
+ for (const status of ['-2', '02', '2', 'UNKNOWN', '', 'unexpected', 'constructor', 'toString', '__proto__']) {
+ expect(diagnosisSessionStatusDisplay(status)).toBe('-')
+ }
+ expect(diagnosisOnlineStatusDisplay()).toBe('-')
+ expect(diagnosisSessionStatusDisplay()).toBe('-')
+ })
+ it('保留明确的在线、离线和不支持状态', () => {
+ expect(diagnosisOnlineStatusDisplay('1')).toBe('在线')
+ expect(diagnosisOnlineStatusDisplay('0')).toBe('离线')
+ expect(diagnosisSessionStatusDisplay('0')).toBe('在线')
+ expect(diagnosisSessionStatusDisplay('-1')).toBe('离线')
+ expect(diagnosisSessionStatusDisplay('UNSUPPORTED')).toBe('不支持')
+ })
+})
+
+describe('实时会话状态优先', () => {
+ it('明确会话状态覆盖汇总状态,异常状态不回退旧在线值', () => {
+ expect(resolveDiagnosisOnlineStatus('0', '0')).toBe('1')
+ expect(resolveDiagnosisOnlineStatus('1', '-1')).toBe('0')
+ for (const status of ['-2', '02', '2', 'UNKNOWN', 'unexpected', 'UNSUPPORTED']) {
+ expect(resolveDiagnosisOnlineStatus('1', status)).toBe('2')
+ }
+ expect(resolveDiagnosisOnlineStatus('1', undefined)).toBe('1')
+ })
+})
+
+describe('数据服务来源隔离', () => {
+ it('电信展示断网状态,不使用 APN 开关值代替', () => {
+ expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '1', dataStatus: '1' })).toBe('已断网')
+ expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '0', dataStatus: '0' })).toBe('未断网')
+ })
+ it('电信缺少断网状态时显示横线,不回退 APN 开关', () => {
+ expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', dataStatus: '1' })).toBe('-')
+ expect(diagnosisDataServiceDisplay({ dataServiceSource: 'network_block', netBlockStatus: '2' })).toBe('-')
+ })
+ it('移动仍使用 APN 开停状态', () => {
+ expect(diagnosisDataServiceDisplay({ dataServiceSource: 'apn', dataStatus: '1' })).toBe('开启')
+ expect(diagnosisDataServiceDisplay({ dataServiceSource: 'apn', dataStatus: '0' })).toBe('关闭')
+ expect(diagnosisDataServiceDisplay({})).toBe('-')
+ })
+})
+
+describe('diagnosis display rules', () => {
+ it('hides unsubscribed and unsupported modules', () => {
+ expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'not_subscribed' })).toBe(true)
+ expect(shouldHideDiagnosisModule({ status: 'skipped', message: '该卡未订购机卡绑定服务' })).toBe(true)
+ })
+
+ it('keeps no-data and query-error modules visible', () => {
+ expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'no_data' })).toBe(false)
+ expect(shouldHideDiagnosisModule({ status: 'error', reason: 'query_error' })).toBe(false)
+ expect(shouldHideDiagnosisModule({ status: 'skipped', message: '查询暂时未完成,请稍后重试' })).toBe(false)
+ })
+
+ it('shows unsupported modules as unsupported', () => {
+ expect(isDiagnosisUnsupportedModule({ status: 'skipped', reason: 'unsupported' })).toBe(true)
+ expect(isDiagnosisUnsupportedModule({ status: 'skipped', message: '运营商暂不支持' })).toBe(true)
+ expect(isDiagnosisUnsupportedModule({ status: 'skipped' })).toBe(true)
+ expect(shouldHideDiagnosisModule({ status: 'skipped', reason: 'unsupported' })).toBe(false)
+ })
+
+ it('normalizes empty data to a dash without hiding it', () => {
+ expect(isDiagnosisNoDataText('')).toBe(true)
+ expect(isDiagnosisNoDataText('暂无数据')).toBe(true)
+ expect(shouldHideDiagnosisText('暂无数据')).toBe(false)
+ expect(shouldHideDiagnosisText('运营商暂不支持')).toBe(false)
+ })
+
+ it('does not treat a failed flow query default zero as real usage', () => {
+ expect(pickDiagnosisFlowValue(
+ { status: 'error', reason: 'query_error' },
+ [null, undefined],
+ [0],
+ )).toBeNull()
+ })
+
+ it('keeps successful decimal flow usage and real zero values', () => {
+ expect(pickDiagnosisFlowValue({ status: 'success' }, [0.03], [0])).toBe(0.03)
+ expect(pickDiagnosisFlowValue({ status: 'cached' }, [null], [0])).toBe(0)
+ })
+
+ it('does not show default remaining flow as upstream data', () => {
+ expect(pickDiagnosisRemainingFlowValue({ status: 'error' }, [0])).toBeNull()
+ expect(pickDiagnosisRemainingFlowValue({ status: 'success' }, [null, undefined])).toBeNull()
+ expect(pickDiagnosisRemainingFlowValue({ status: 'success' }, [0])).toBe(0)
+ })
+
+ it('only shows a retry hint for reachable business failures', () => {
+ expect(diagnosisFlowRetryHint({ status: 'error', message: '服务繁忙,请重试' })).toBe('服务繁忙,请重试')
+ expect(diagnosisFlowRetryHint({ status: 'error' })).toBe('')
+ expect(diagnosisFlowRetryHint({ status: 'success', message: '服务繁忙,请重试' })).toBe('')
+ })
+})
diff --git a/app/app/src/pages/business/views/diagnosis/display.ts b/app/app/src/pages/business/views/diagnosis/display.ts
new file mode 100644
index 0000000..ca7738d
--- /dev/null
+++ b/app/app/src/pages/business/views/diagnosis/display.ts
@@ -0,0 +1,133 @@
+import type { DiagnosisInfoItem, DiagnosisModuleItem } from '@/api/diagnosis'
+
+export function diagnosisOnlineStatusDisplay(status?: string): string {
+ const labels: Record = { 0: '离线', 1: '在线' }
+ const value = String(status ?? '').trim()
+ return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
+}
+
+export function diagnosisSessionStatusDisplay(status?: string): string {
+ const labels: Record = {
+ '0': '在线',
+ '01': '在线',
+ 'ONLINE': '在线',
+ '-1': '离线',
+ '00': '离线',
+ 'OFFLINE': '离线',
+ 'UNSUPPORTED': '不支持',
+ }
+ const value = String(status ?? '').trim()
+ return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
+}
+
+export function resolveDiagnosisOnlineStatus(
+ infoStatus?: string | number,
+ sessionStatus?: string | number,
+): string {
+ const status = String(sessionStatus ?? '').trim()
+ switch (status) {
+ case '0':
+ case '01':
+ case 'ONLINE':
+ return '1'
+ case '-1':
+ case '00':
+ case 'OFFLINE':
+ return '0'
+ default:
+ return status ? '2' : String(infoStatus ?? '').trim()
+ }
+}
+
+export function diagnosisDataServiceDisplay(info?: DiagnosisInfoItem): string {
+ const networkBlock = info?.dataServiceSource === 'network_block'
+ const labels: Record = networkBlock
+ ? { 0: '未断网', 1: '已断网' }
+ : { '0': '关闭', '1': '开启', '-1': '未知' }
+ const status = networkBlock ? info?.netBlockStatus : info?.dataStatus
+ const value = String(status ?? '').trim()
+ return Object.prototype.hasOwnProperty.call(labels, value) ? labels[value] : '-'
+}
+
+const NOT_SUBSCRIBED_MESSAGE_RE = /未订购|未开通|未申请|通道未申请/
+const UNSUPPORTED_MESSAGE_RE = /不支持|暂不支持|暂不提供|暂未提供|未提供独立|无独立.*接口|当前无.*接口/
+const NO_DATA_MESSAGE_RE = /未返回|未查询到|无数据|暂无数据|暂时未完成/
+
+export function shouldHideDiagnosisModule(module?: DiagnosisModuleItem) {
+ const reason = String(module?.reason || '').trim()
+ if (reason === 'not_subscribed')
+ return true
+ if (reason === 'unsupported' || reason === 'no_data' || reason === 'query_error')
+ return false
+
+ const status = String(module?.status || '').trim()
+ const message = String(module?.message || '').trim()
+ return NOT_SUBSCRIBED_MESSAGE_RE.test(message)
+}
+
+export function isDiagnosisUnsupportedModule(module?: DiagnosisModuleItem) {
+ const reason = String(module?.reason || '').trim()
+ if (reason === 'unsupported')
+ return true
+ if (reason === 'not_subscribed' || reason === 'no_data' || reason === 'query_error')
+ return false
+ const status = String(module?.status || '').trim()
+ const message = String(module?.message || '').trim()
+ return UNSUPPORTED_MESSAGE_RE.test(message) || (status === 'skipped' && !message)
+}
+
+export function isDiagnosisNoDataText(value: unknown) {
+ const text = String(value ?? '').trim()
+ return !text
+ || text === '-'
+ || text === ''
+ || text === 'undefined'
+ || text === 'null'
+ || NO_DATA_MESSAGE_RE.test(text)
+}
+
+export function shouldHideDiagnosisText(value: unknown) {
+ return NOT_SUBSCRIBED_MESSAGE_RE.test(String(value ?? '').trim())
+}
+
+export function pickDiagnosisFlowValue(
+ module: DiagnosisModuleItem | undefined,
+ primaryValues: unknown[],
+ fallbackValues: unknown[],
+) {
+ const primary = firstFiniteNumber(primaryValues)
+ if (primary !== null)
+ return primary
+
+ const status = String(module?.status || '').trim()
+ if (module && status !== 'success' && status !== 'cached')
+ return null
+
+ return firstFiniteNumber(fallbackValues)
+}
+
+export function diagnosisFlowRetryHint(module?: DiagnosisModuleItem) {
+ if (String(module?.status || '').trim() !== 'error')
+ return ''
+ return String(module?.message || '').trim() === '服务繁忙,请重试'
+ ? '服务繁忙,请重试'
+ : ''
+}
+
+export function pickDiagnosisRemainingFlowValue(module: DiagnosisModuleItem | undefined, values: unknown[]) {
+ const status = String(module?.status || '').trim()
+ if (status !== 'success' && status !== 'cached')
+ return null
+ return firstFiniteNumber(values)
+}
+
+function firstFiniteNumber(values: unknown[]) {
+ for (const value of values) {
+ if (value === undefined || value === null || value === '')
+ continue
+ const number = Number(value)
+ if (Number.isFinite(number))
+ return number
+ }
+ return null
+}
diff --git a/app/app/src/pages/business/views/diagnosis/index.vue b/app/app/src/pages/business/views/diagnosis/index.vue
new file mode 100644
index 0000000..e1abc76
--- /dev/null
+++ b/app/app/src/pages/business/views/diagnosis/index.vue
@@ -0,0 +1,1421 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 卡片排查
+
+ {{ hasDiagnosed ? cardSubtitle : '输入卡号查看关键状态' }}
+
+
+
+
+ {{ diagnosisSummary.text }}
+
+
+
+
+
+
+
+
+ 开始排查
+
+
+
+
+
+
+
+ 本页基于卡片状态、在线状态、流量和设备信息进行状态排查,结果仅供参考,最终以运营商和业务系统状态为准。请勿输入身份证号、银行卡号、密码等敏感信息。
+
+
+
+
+
+ 正在排查...
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重新排查
+
+
+
+
+
+
+
+
+ {{ row.label }}
+
+
+
+
+
+ {{ row.desc }}
+
+
+
+
+ {{ row.value }}
+
+
+ {{ row.hint }}
+
+
+
+
+ {{ row.value }}
+
+
+
+
+ {{ row.value }}
+
+
+ {{ row.value }}
+
+
+
+
+
+ 重启卡
+
+
+
+ 开通数据服务
+
+
+
+ 停用数据服务
+
+
+
+
+
+
+
+
+
+ 流量使用分析
+ 近期用量趋势仅作为诊断参考
+
+
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+ 正在分析流量趋势...
+
+
+
+
+
+ {{ flowErrorMessage }}
+
+
+ 重新加载
+
+
+
+
+
+
+ 周期总用量
+ {{ formatFlowValue(flowTotal) }}
+
+
+ {{ activeFlowPeriod === '30d' ? '日均用量' : '月均用量' }}
+ {{ formatFlowValue(flowAverage) }}
+
+
+ 单期峰值
+ {{ formatFlowValue(flowPeak) }}
+
+
+
+
+
+ 含 {{ flowMissingCount }} 个未采集周期,未按 0MB 计入统计
+
+
+ {{ label }}
+
+
+
+
+
+
+
+ 暂无历史流量数据
+ 该周期内没有可用于趋势分析的记录
+
+
+
+
+
+
+
+
+ {{ group.title }}
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.value }}
+
+
+
+
+
+ 查看更多状态信息
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/order/index.vue b/app/app/src/pages/business/views/order/index.vue
new file mode 100644
index 0000000..81bc6f7
--- /dev/null
+++ b/app/app/src/pages/business/views/order/index.vue
@@ -0,0 +1,586 @@
+
+
+
+
+
+
+
+
+
+
+ 我的订单
+
+
+ {{ summaryText }}
+
+
+
+ 刷新
+
+
+
+
+
+
+
+
+
+ {{ option.payload?.label || option.value }}
+
+
+
+
+ {{ option.payload?.label || option.value }}
+
+
+
+
+
+
+
+
+
+
+ {{ item.totalAmountText }}
+
+
+
+ {{ displayStatus(item) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 加载更多
+
+
+
+
+
+
+
+
+ 订单详情
+
+
+ {{ selectedOrder?.orderSn || '-' }}
+
+
+
+ 关闭
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 反馈订单问题
+
+
+
+
+ 业务内容
+
+
+
+
+
+ {{ item.priceText }}
+
+
+
+
+
+
+
+
+
+ 继续支付
+
+
+
+ 申请退款
+
+
+
+ {{ refundStatusText(selectedOrder) }}
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/renew/index.vue b/app/app/src/pages/business/views/renew/index.vue
new file mode 100644
index 0000000..b61d05d
--- /dev/null
+++ b/app/app/src/pages/business/views/renew/index.vue
@@ -0,0 +1,729 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 批量套餐续费
+ 选择同套餐卡片,校验后统一续费并顺延到期时间
+
+
+
+ 批量
+
+
+
+
+
+ 已选卡片
+ {{ selectedCardValues.length }} 张
+
+
+ 当前套餐
+ {{ currentPackageName() }}
+
+
+ 续费套餐
+ {{ packageName() }}
+
+
+ 校验状态
+ {{ prepare ? '已校验' : '待校验' }}
+
+
+ 应付金额
+ {{ selectedOption ? amountText : '待计算' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loadingCards ? '刷新中' : '刷新' }}
+
+
+
+
+
+
+ 全选
+
+
+
+
+
+ 清空
+
+
+
+
+
+
+
+
+ 当前可续费卡
+
+
+ 已选 {{ selectedCardValues.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ cardKey(item) }}
+
+
+ {{ displayPackageName(item) }}
+
+
+ {{ formatFlowPair(item) }} · 到期 {{ formatDateTime(item.flowPkgEndAt) }}
+
+
+
+
+
+
+
+
+ 展开全部 {{ visibleCards.length }} 张卡
+
+
+
+
+ 收起列表
+
+
+
+
+ 暂无可续费卡片
+
+
+
+
+
+
+ 校验结果
+
+ 已生成批次
+
+
+
+ 预处理批次
+ {{ prepare.batchNo || '-' }}
+
+
+ 统一套餐
+ {{ packageName() }}
+
+
+ 校验卡数
+ {{ prepare.quantity || selectedCardValues.length }} 张
+
+
+ 单年价格
+ {{ formatMoney(prepare.unitPrice) }}
+
+
+
+
+
+
+
+ 办理结果
+
+ 已支付
+
+
+
+ 订单号
+ {{ order.orderSn || '-' }}
+
+
+ 续费批次
+ {{ order.batchNo || '-' }}
+
+
+ 订单金额
+ {{ order.priceText || formatMoney(order.totalAmount) }}
+
+
+
+
+
+
+
+
+ 选择续费档位
+
+
+ {{ prepare?.quantity || selectedCardValues.length }} 张卡统一续费
+
+
+
+
+
+
+
+
+ 1
+
+ 选择续费套餐
+
+
+
+
+
+
+ {{ item.packageName || item.package?.packageName || renewPackageCode(item) || '-' }}
+
+
+ 已选
+
+
+
+ {{ item.priceText || formatMoney(item.renewUnitPrice || item.unitPrice) }} · {{ item.options?.length || item.renewableDurationOptions?.length || 0 }} 个可续费年限
+
+
+ {{ item.priceText || formatMoney(item.renewUnitPrice || item.unitPrice) }}
+
+
+
+
+
+ 当前套餐暂无可续费套餐
+
+
+
+
+
+
+ 2
+
+ 选择续费年限
+
+
+
+
+
+ {{ item.label }}
+
+ 推荐
+
+
+
+ {{ optionTag(item, index) }} · 到期 {{ formatDateTime(item.expireAfter) }}
+
+
+ {{ item.priceText || formatMoney(item.totalAmount) }}
+
+
+
+
+
+ 当前续费套餐暂无可续费年限
+
+
+
+
+
+
+
+
+ 当前套餐
+ {{ currentPackageName() }}
+
+
+ 续费套餐
+ {{ packageName() }}
+
+
+ 续费卡数
+ {{ prepare.quantity || selectedCardValues.length }} 张
+
+
+ 生效规则
+ {{ prepare.effectiveRule }}
+
+
+ 应付金额
+ {{ amountText }}
+
+
+
+
+
+
+ 取消
+
+
+ 确认续费
+
+
+
+
+
+
+
+
+
+
+ 已选 {{ selectedCardValues.length }} 张
+ {{ selectedOption ? `应付 ${amountText}` : '先校验可续费档位' }}
+
+
+
+ 校验档位
+
+
+ 确认续费 {{ selectedOption ? amountText : '' }}
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/warning/index.vue b/app/app/src/pages/business/views/warning/index.vue
new file mode 100644
index 0000000..b775fa1
--- /dev/null
+++ b/app/app/src/pages/business/views/warning/index.vue
@@ -0,0 +1,506 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 流量预警
+ 选择卡片后统一配置流量预警阈值,预警通知在消息中心查看
+
+
+
+ 账号级提醒设置
+
+
+
+
+
+ 已选卡片
+ {{ selectedText }} 张
+
+
+
+
+
+
+
+
+
+
+
+
+ 预警阈值
+
+ 第一次提醒必填,第二次和第三次可按需填写
+
+
+
+ 单卡批量
+
+
+
+
+
+
+
+
+
+
+
+ {{ form.title }}
+
+
+
+ {{ form.desc }}
+
+
+
+
+
+
+
+
+ {{ form.unitText }}
+
+
+
+
+ {{ form.unitText }}
+
+
+
+
+ {{ form.unitText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 刷新
+
+
+
+
+
+ 全选
+
+
+
+
+
+ 清空
+
+
+
+
+
+
+
+
+
+ 选择卡片
+
+ 最多选择 100 张统一配置
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+ 重试
+
+
+
+
+
+
+
+
+
+
+
+ {{ cardKey(item) }}
+ {{ displayPackageName(item) }}
+
+ {{ formatFlowPair(item) }}
+
+
+
+
+
+
+
+
+ 展开全部 {{ visibleCards.length }} 张卡
+
+
+
+
+ 收起列表
+
+
+
+
+ 暂无可配置卡片
+
+
+
+
+
+
+
+
+ 已选 {{ selectedCardValues.length }} 张
+ 预警通知在消息中心查看
+
+
+ 保存预警配置
+
+
+
+
diff --git a/app/app/src/pages/business/views/workOrder/index.test.ts b/app/app/src/pages/business/views/workOrder/index.test.ts
new file mode 100644
index 0000000..7b7a0d6
--- /dev/null
+++ b/app/app/src/pages/business/views/workOrder/index.test.ts
@@ -0,0 +1,22 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+describe('工单列表筛选布局', () => {
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+
+ it('六个状态使用一行标签而非换行按钮', () => {
+ expect(source).toContain(' {
+ const searchEnd = source.indexOf('@clear="loadWorkOrders()"')
+ const tabsStart = source.indexOf('')
+ expect(source).toContain('watch(status, () => {')
+ expect(source).toContain('currentStatus: status.value || undefined')
+ })
+})
diff --git a/app/app/src/pages/business/views/workOrder/index.vue b/app/app/src/pages/business/views/workOrder/index.vue
new file mode 100644
index 0000000..44c1fb0
--- /dev/null
+++ b/app/app/src/pages/business/views/workOrder/index.vue
@@ -0,0 +1,283 @@
+
+
+
+
+
+
+
+ 当前账号不能使用工单。
+
+
+
+
+
+
+
+
+ 我的工单
+
+
+ {{ canViewWorkOrder ? `共 ${total} 条,按最新提交时间排序` : '当前账号仅可提交新工单' }}
+
+
+
+ 提交工单
+
+
+
+
+
+
+
+
+
+
+ 可以提交新工单
+
+
+ 当前角色未配置工单列表权限,提交后可由平台客服继续处理。
+
+
+ 提交工单
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loadError }}
+
+
+ 重新加载
+
+
+
+
+
+ 暂无工单
+
+
+
+
+
+
+ {{ item.title }}
+
+
+ {{ item.currentStatusText || '处理中' }}
+
+
+
+
+ {{ item.woTypeText || '工单' }} · {{ workOrderTime(item) }}
+
+
+ {{ item.woNo }}
+
+ {{ [4, 5].includes(item.currentStatus) ? '查看详情' : '沟通跟进' }}
+
+
+
+
+
+ 加载更多
+
+
+ 已显示全部工单
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/workOrderCreate/index.test.ts b/app/app/src/pages/business/views/workOrderCreate/index.test.ts
new file mode 100644
index 0000000..6819935
--- /dev/null
+++ b/app/app/src/pages/business/views/workOrderCreate/index.test.ts
@@ -0,0 +1,117 @@
+import { readFileSync } from 'node:fs'
+import { createContext, runInContext } from 'node:vm'
+import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
+import { describe, expect, it, vi } from 'vitest'
+
+const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+
+function harness(create = vi.fn().mockResolvedValue({ workOrder: { woNo: 'WO-1' } })) {
+ const context = createContext({
+ exports: {},
+ require: (name: string) => {
+ if (name === '@/store/user')
+ return { useUserStore: () => ({ userInfo: { phone: '13800008000' } }) }
+ if (name === '../api/workOrder')
+ return { createWorkOrder: create }
+ if (name === '@/utils/permission')
+ return { hasAnyFeature: () => true }
+ return {}
+ },
+ uni: { showToast: vi.fn() },
+ setTimeout: vi.fn(),
+ ref: (value: unknown) => ({ value }),
+ reactive: (value: unknown) => value,
+ computed: (getter: () => unknown) => ({ get value() { return getter() } }),
+ definePage: vi.fn(),
+ onLoad: vi.fn(),
+ onMounted: vi.fn(),
+ })
+ const script = source.match(/
+
+
+
+
+
+
+ 当前账号不能提交工单。
+
+
+
+
+
+
+
+
+
+
+ 提交工单
+
+ 提交后由服务部门跟进,可在工单详情查看进度
+
+
+
+
+
+
+
+
+
+
+ 基础信息
+
+ {{ typeText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 问题描述
+
+
+
+
+
+
+
+
+
+
+
+ 提交工单
+
+
+
+
+
+
diff --git a/app/app/src/pages/business/views/workOrderDetail/index.test.ts b/app/app/src/pages/business/views/workOrderDetail/index.test.ts
new file mode 100644
index 0000000..1222fc4
--- /dev/null
+++ b/app/app/src/pages/business/views/workOrderDetail/index.test.ts
@@ -0,0 +1,316 @@
+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 { isWorkOrderImage, workOrderAttachmentFilename, workOrderAttachmentName } from '@/utils/workOrderAttachment'
+
+function harness() {
+ const fetchUserInfo = vi.fn().mockResolvedValue(undefined)
+ const act = vi.fn().mockResolvedValue({})
+ const detail = vi.fn().mockResolvedValue({ workOrder: { actions: ['reply'], communications: [] } })
+ const lifecycle: Record unknown> = {}
+ const setInterval = vi.fn().mockReturnValue(1)
+ const clearInterval = vi.fn()
+ const downloadFile = vi.fn()
+ const openDocument = vi.fn()
+ const fileManager = {
+ mkdir: vi.fn((options: { success: () => void }) => options.success()),
+ copyFile: vi.fn((options: { srcPath: string, destPath: string, success: () => void }) => options.success()),
+ unlink: vi.fn((options: { complete: () => void }) => options.complete()),
+ rmdir: vi.fn(),
+ }
+ const context = createContext({
+ setInterval,
+ clearInterval,
+ exports: {},
+ require: (name: string) => {
+ if (name === '@dcloudio/uni-app') {
+ return {
+ onLoad: (callback: () => unknown) => { lifecycle.load = callback },
+ onShow: (callback: () => unknown) => { lifecycle.show = callback },
+ onHide: (callback: () => unknown) => { lifecycle.hide = callback },
+ onUnload: (callback: () => unknown) => { lifecycle.unload = callback },
+ }
+ }
+ if (name === '../api/workOrder')
+ return { actOnWorkOrder: act, getWorkOrderDetail: detail }
+ if (name === '@/store/user')
+ return { useUserStore: () => ({ userInfo: {}, fetchUserInfo }) }
+ if (name === '@/utils/workOrderAttachment')
+ return { isWorkOrderImage, workOrderAttachmentName, workOrderAttachmentFilename }
+ if (name === '@/utils')
+ return { getEnvBaseUrl: () => 'https://api.example.com/api/v1' }
+ if (name === '@/utils/permission')
+ return { hasAnyFeature: (user: { revoked?: boolean }) => !user.revoked }
+ return {}
+ },
+ ref: (value: unknown) => ({ value }),
+ computed: (getter: () => unknown) => ({ get value() { return getter() } }),
+ nextTick: () => Promise.resolve(),
+ definePage: vi.fn(),
+ uni: { showToast: vi.fn(), previewImage: vi.fn(), downloadFile, openDocument },
+ wx: { env: { USER_DATA_PATH: '/user-data' }, getFileSystemManager: () => fileManager },
+ })
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+ const script = source.match(/
+
+
+
+
+
+
+ 当前账号不能查看工单详情。
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ workOrder.title }}
+
+
+ {{ workOrder.currentStatus === 3 ? '待确认解决' : (workOrder.currentStatusText || '处理中') }}
+
+
+
+
+
+
+
+
+
+
+
+ 问题描述
+
+ {{ workOrder.attachmentUrls.length }} 个附件
+
+
+
+
+
+ 文字说明
+
+
+ {{ workOrder.content }}
+
+
+
+
+ 相关附件
+
+
+
+
+
+
+ 暂无问题描述
+
+
+
+
+
+ {{ canReply ? '继续沟通' : '查看记录' }}
+
+
+
+
+ 处理进度
+
+
+ {{ record.operationText }}
+ {{ formatTime(record.createdAt) }}
+
+
+
+
+
+ {{ autoCloseText }}
+
+
+
+
+
+ 评价本次服务
+
+
+
+
+ 提交评价
+
+
+
+
+
+ 我的评价
+
+
+
+ {{ workOrder.evalContent || '未填写评价内容' }}
+
+
+
+
+
+ 工单办理
+
+
+
+ 标记解决
+
+
+ 转单
+
+
+ 关单
+
+
+
+
+
+ 确认解决并完结
+
+
+
+
+
+ 刷新
+
+
+
+
+ 取消
+
+
+
+
+
+
+
+
+ 工单暂时无法加载
+
+
+ 重试
+
+
+
+
+
+
+
+
+ 工单沟通
+
+
+ {{ workOrder.title }}
+
+
+
+ 收起
+
+
+
+
+ 标记解决
+
+
+ 转单
+
+
+ 关单
+
+
+
+
+
+ {{ workOrder.currentStatusText }} · 每15秒更新沟通记录
+
+
+
+ 查看更早的消息
+
+
+
+
+ {{ item.senderName || (item.commType === 1 ? '提交人' : '处理人员') }} · {{ formatTime(item.createdAt) }}
+
+
+ {{ item.content }}
+
+
+
+
+
+
+
+
+ 还没有消息,可以在下方补充问题
+
+
+
+
+
+
+ 回到最新消息
+
+
+
+
+ 问题仍未解决?发送原因后将重新进入处理。
+
+
+
+
+
+
+ 支持文字、图片和文件
+
+ {{ actions.includes('reopen') ? '反馈未解决' : '发送' }}
+
+
+
+
+ 当前工单不可回复,历史消息仍可查看
+
+
+
+
+
diff --git a/app/app/src/pages/card/action.test.ts b/app/app/src/pages/card/action.test.ts
new file mode 100644
index 0000000..15acbef
--- /dev/null
+++ b/app/app/src/pages/card/action.test.ts
@@ -0,0 +1,23 @@
+import type { AppCard } from '@/api/card'
+import { describe, expect, it } from 'vitest'
+import { isV101HiddenRoute, V101_RELEASE } from '@/constant/release'
+import { buildCardMoreActions } from './action'
+
+const user = { userId: 12, deptId: 100, userType: 'sys_user', appAccess: { configured: true, features: ['card:allocate'] } }
+const card = { ownerType: 'enterprise', canBusiness: true, canAllocate: true } as AppCard
+
+describe('划拨单卡入口发布与权限', () => {
+ it('只开放授权企业卡的划拨,不暴露其他操作', () => {
+ expect(V101_RELEASE.showCardMoreActions).toBe(false)
+ expect(buildCardMoreActions(user, card).map(item => item.key)).toEqual(['allocate'])
+ expect(isV101HiddenRoute('/pages/business/views/allocate/index')).toBe(false)
+ expect(isV101HiddenRoute('/pages/business/views/change/index')).toBe(true)
+ })
+ it('无权限、终端、公共卡、不可办理和不可划拨卡不显示按钮', () => {
+ expect(buildCardMoreActions({ ...user, appAccess: { configured: true, features: [] } }, card)).toEqual([])
+ expect(buildCardMoreActions({ ...user, userType: 'app_user' }, card)).toEqual([])
+ expect(buildCardMoreActions(user, { ...card, ownerType: 'public' })).toEqual([])
+ expect(buildCardMoreActions(user, { ...card, canBusiness: false })).toEqual([])
+ expect(buildCardMoreActions(user, { ...card, canAllocate: false })).toEqual([])
+ })
+})
diff --git a/app/app/src/pages/card/action.ts b/app/app/src/pages/card/action.ts
new file mode 100644
index 0000000..a369a7c
--- /dev/null
+++ b/app/app/src/pages/card/action.ts
@@ -0,0 +1,141 @@
+import type { AppCard } from '@/api/card'
+import type { IUserInfoRes } from '@/api/types/login'
+import { hasUserPermission } from '@/utils/permission'
+import { getAccountWorkspace, isEnterpriseUserInfo } from '@/utils/userIdentity'
+import { V101_RELEASE } from '@/constant/release'
+
+export type CardMoreActionKey = 'restart' | 'stop' | 'start' | 'dataStart' | 'dataStop' | 'change' | 'allocate' | 'warning'
+
+export interface CardMoreAction {
+ key: CardMoreActionKey
+ name: string
+ description?: string
+ color?: string
+}
+
+export function canBusinessCardAction(
+ userInfo: Partial | null | undefined,
+ card?: AppCard | null,
+) {
+ const hasUser = Boolean(userInfo?.userId || userInfo?.phone || userInfo?.nickName || userInfo?.username)
+ return hasUser && !!card && card.ownerType !== 'public' && card.canBusiness !== false
+}
+
+export function canRenewCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'renew')
+}
+
+export function canRestartCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'restart')
+}
+
+export function canAddonCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'addon')
+}
+
+export function canEnterpriseCardAction(
+ userInfo: Partial | null | undefined,
+ card?: AppCard | null,
+) {
+ const workspace = getAccountWorkspace(userInfo)
+ return (isEnterpriseUserInfo(userInfo) || workspace === 'agent' || workspace === 'admin')
+ && !!card
+ && card.ownerType !== 'public'
+ && card.canBusiness !== false
+}
+
+export function canAllocateCardAction(
+ userInfo: Partial | null | undefined,
+ card?: AppCard | null,
+) {
+ return canEnterpriseCardAction(userInfo, card) && hasUserPermission(userInfo, 'allocate') && card?.canAllocate !== false
+}
+
+export function canStopCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'stop')
+}
+
+export function canStartCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'start')
+}
+
+export function canDataStartCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'dataStart')
+}
+
+export function canDataStopCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'dataStop')
+}
+
+export function canChangeCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canEnterpriseCardAction(userInfo, card) && hasUserPermission(userInfo, 'change')
+}
+
+export function canWarningCardAction(userInfo: Partial | null | undefined, card?: AppCard | null) {
+ return canBusinessCardAction(userInfo, card) && hasUserPermission(userInfo, 'warning')
+}
+
+export function buildCardMoreActions(
+ userInfo: Partial | null | undefined,
+ card?: AppCard | null,
+) {
+ if (!V101_RELEASE.showCardMoreActions) {
+ return canAllocateCardAction(userInfo, card)
+ ? [{ key: 'allocate', name: '卡片划拨', description: '将这张卡划拨给直属客户' }] satisfies CardMoreAction[]
+ : []
+ }
+
+ const actions: CardMoreAction[] = []
+
+ if (card?.bossCardStatus === 4) {
+ if (canStartCardAction(userInfo, card)) {
+ actions.push({
+ key: 'start',
+ name: '复机',
+ description: '恢复卡片联网',
+ })
+ }
+ }
+ else if (canStopCardAction(userInfo, card)) {
+ actions.push({
+ key: 'stop',
+ name: '停机',
+ description: '暂停卡片联网',
+ color: '#e11d48',
+ })
+ }
+
+ if (card?.dataServiceStatus === 1 && canDataStopCardAction(userInfo, card)) {
+ actions.push({
+ key: 'dataStop',
+ name: '停用数据服务',
+ description: '关闭这张卡的数据通信',
+ color: '#e11d48',
+ })
+ }
+ else if (card?.dataServiceStatus === 0 && canDataStartCardAction(userInfo, card)) {
+ actions.push({
+ key: 'dataStart',
+ name: '开通数据服务',
+ description: '恢复这张卡的数据通信',
+ })
+ }
+
+ if (canChangeCardAction(userInfo, card)) {
+ actions.push({
+ key: 'change',
+ name: '套餐变更',
+ description: '切换当前套餐',
+ })
+ }
+
+ if (canAllocateCardAction(userInfo, card)) {
+ actions.push({
+ key: 'allocate',
+ name: '卡片划拨',
+ description: '划拨权随卡片归属转移',
+ })
+ }
+
+ return actions
+}
diff --git a/app/app/src/pages/card/components/CardList.vue b/app/app/src/pages/card/components/CardList.vue
new file mode 100644
index 0000000..2ca3ae4
--- /dev/null
+++ b/app/app/src/pages/card/components/CardList.vue
@@ -0,0 +1,934 @@
+
+
+
+
+
+
+
+
+
+ 卡片列表加载中...
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayCardValue(item) }}
+
+
+ {{ item.msisdn || item.terminalName || '物联网卡' }} ·
+ {{ item.packageName || '未配置套餐' }}
+
+
+
+ 剩余 {{ formatRemainFlow(item) }}
+
+
+ {{ getBossCardStatusText(item.bossCardStatus) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayCardValue(item) }}
+
+ {{ item.terminalName || item.msisdn || '物联网卡' }} · 剩余
+ {{ formatRemainFlow(item) }}
+
+
+
+
+
+
+ {{ getBossCardStatusText(item.bossCardStatus) }}
+
+
+
+
+
+
+
+
+
+ {{ displayCardValue(item) }}
+
+
+ {{ item.msisdn || item.terminalName || '物联网卡' }}
+
+
+
+
+ {{ getBossCardStatusText(item.bossCardStatus) }}
+
+
+
+
+
+ 归属客户
+ {{ ownerDeptText(item) }}
+
+
+
+
+
+ {{ getCardAbnormalReasonText(item) }}
+
+
+
+
+
+ 剩余流量
+
+ {{ formatRemainFlow(item) }}
+
+
+
+
+
+
+
+
+
+ 详情
+
+
+ {{ V101_RELEASE.showCardMoreActions ? '更多' : '划拨' }}
+
+
+
+
+
+
+ 还有 {{ hiddenPreviewCount }} 张卡,点击右上角管理查看完整列表
+
+
+
+
+
+ 正在加载更多...
+
+
+ 加载更多(已加载 {{ cards.length }}/{{ total }})
+
+
+ {{
+ cards.length >= MAX_LOADED_CARDS && cards.length < total
+ ? `已加载前 ${MAX_LOADED_CARDS} 张,请使用搜索缩小范围`
+ : `已加载全部 ${total} 张卡`
+ }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/card/components/CardToolbar.vue b/app/app/src/pages/card/components/CardToolbar.vue
new file mode 100644
index 0000000..7376ca1
--- /dev/null
+++ b/app/app/src/pages/card/components/CardToolbar.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/card/components/EmptyState.vue b/app/app/src/pages/card/components/EmptyState.vue
new file mode 100644
index 0000000..eb26354
--- /dev/null
+++ b/app/app/src/pages/card/components/EmptyState.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ {{ title }}
+
+
+ {{ desc }}
+
+
+
+
diff --git a/app/app/src/pages/card/components/FilterBar.vue b/app/app/src/pages/card/components/FilterBar.vue
new file mode 100644
index 0000000..5e633c4
--- /dev/null
+++ b/app/app/src/pages/card/components/FilterBar.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
diff --git a/app/app/src/pages/card/components/PackageList.vue b/app/app/src/pages/card/components/PackageList.vue
new file mode 100644
index 0000000..908e7a8
--- /dev/null
+++ b/app/app/src/pages/card/components/PackageList.vue
@@ -0,0 +1,217 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+
+
+
+ {{ displayPackageName(item) }}
+
+
+ {{ item.packageTypeName || '-' }} · {{ displayCardValue(item) }}
+
+
+
+ {{ item.status || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 续费
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/card/data.test.ts b/app/app/src/pages/card/data.test.ts
new file mode 100644
index 0000000..464f68b
--- /dev/null
+++ b/app/app/src/pages/card/data.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { getCardAbnormalReasonText, matchesCardFilter } from './data'
+
+describe('card status helpers', () => {
+ it('shows expired package reason even when boss card status is activated', () => {
+ const card = {
+ iccid: '898604B21522C0012933',
+ bossCardStatus: 2,
+ packageStatus: 3,
+ }
+
+ expect(matchesCardFilter(card, 'error')).toBe(true)
+ expect(getCardAbnormalReasonText(card)).toBe('套餐已过期')
+ })
+
+ it('uses abnormal reasons returned by backend first', () => {
+ expect(getCardAbnormalReasonText({
+ iccid: '898604B21522C0012934',
+ bossCardStatus: 2,
+ packageStatus: 2,
+ abnormalReasons: ['卡状态停机', '套餐已过期'],
+ })).toBe('卡状态停机、套餐已过期')
+ })
+})
diff --git a/app/app/src/pages/card/data.ts b/app/app/src/pages/card/data.ts
new file mode 100644
index 0000000..0a49c2f
--- /dev/null
+++ b/app/app/src/pages/card/data.ts
@@ -0,0 +1,385 @@
+import type { AppCard } from '@/api/card'
+
+export interface FilterItem {
+ label: string
+ value: string
+}
+
+export interface PackageSummary {
+ code: string
+ name: string
+ type: string
+ validText: string
+ status: string
+ statusType: 'danger' | 'primary' | 'success' | 'warning'
+ filterValue: string
+ effectText: string
+ cardCount: number
+ firstCardValue: string
+}
+
+export const bossCardStatusTextMap: Record = {
+ 0: '未知',
+ 1: '待激活',
+ 2: '已激活',
+ 4: '停机',
+ 6: '可测试',
+ 7: '库存',
+ 8: '预销户',
+ 9: '已销户',
+}
+
+export const cardFilters: FilterItem[] = [
+ { label: '全部', value: 'all' },
+ { label: '正常', value: 'normal' },
+ { label: '流量不足', value: 'low-flow' },
+ { label: '即将到期', value: 'expiring' },
+ { label: '异常', value: 'error' },
+]
+
+export const packageFilters: FilterItem[] = [
+ { label: '全部', value: 'all' },
+ { label: '生效中', value: 'active' },
+ { label: '即将到期', value: 'expiring' },
+ { label: '已到期', value: 'expired' },
+ { label: '待生效', value: 'pending' },
+ { label: '加油包', value: 'addon' },
+]
+
+export function getBossCardStatusText(status?: number) {
+ return typeof status === 'number' ? bossCardStatusTextMap[status] || '未知' : '未知'
+}
+
+export function getPackageStatusText(status?: number) {
+ const map: Record = {
+ 0: '无套餐',
+ 1: '待生效',
+ 2: '生效中',
+ 3: '已过期',
+ }
+ return typeof status === 'number' ? map[status] || '未知' : '未知'
+}
+
+export function getCardAbnormalReasons(card: AppCard): string[] {
+ const fromApi = Array.isArray(card.abnormalReasons)
+ ? card.abnormalReasons.map(item => String(item || '').trim()).filter(Boolean)
+ : []
+ if (fromApi.length)
+ return fromApi
+ if (typeof card.abnormalReason === 'string' && card.abnormalReason.trim())
+ return [card.abnormalReason.trim()]
+
+ const reasons: string[] = []
+ if (card.packageStatus === 3)
+ reasons.push('套餐已过期')
+
+ if (card.bossCardStatus === 0) {
+ reasons.push('卡状态未知')
+ }
+ else if (
+ card.bossCardStatus === 4
+ || card.bossCardStatus === 8
+ || card.bossCardStatus === 9
+ ) {
+ reasons.push(`卡状态${getBossCardStatusText(card.bossCardStatus)}`)
+ }
+ else if (typeof card.bossCardStatus === 'number' && !(card.bossCardStatus in bossCardStatusTextMap)) {
+ reasons.push(`未知卡状态:${card.bossCardStatus}`)
+ }
+ return reasons
+}
+
+export function getCardAbnormalReasonText(card: AppCard) {
+ return getCardAbnormalReasons(card).join('、')
+}
+
+export function getServiceStatusText(status?: number) {
+ return status === 1 ? '启用' : '停用'
+}
+
+export function getNetModelText(netModel?: number) {
+ const map: Record = {
+ 1: '3G',
+ 2: '2G',
+ 3: '4G',
+ 4: '5G',
+ 5: 'NB-IOT',
+ 6: '5G-SA',
+ 7: '5G-NSA',
+ 8: '2G/3G',
+ 9: '电信NB',
+ 10: '移动4G',
+ 11: '移动NB',
+ 12: '联通4G',
+ 13: '电信4G',
+ 14: 'RedCap',
+ }
+ return typeof netModel === 'number' ? map[netModel] || '未知' : '未知'
+}
+
+export function getRatText(rat?: string | number) {
+ const value = normalizeStatusValue(rat)
+ if (!value)
+ return '-'
+ const map: Record = {
+ 5: '2G',
+ 6: '4G',
+ 7: '5G',
+ 8: 'NB',
+ }
+ return map[value] || value
+}
+
+export function getCardStatusType(card: AppCard): 'danger' | 'primary' | 'success' | 'warning' {
+ if (
+ card.bossCardStatus === 4
+ || card.bossCardStatus === 8
+ || card.bossCardStatus === 9
+ ) {
+ return 'danger'
+ }
+ if (card.bossCardStatus === 1 || card.bossCardStatus === 7)
+ return 'primary'
+ if (card.bossCardStatus === 2)
+ return 'success'
+ return 'warning'
+}
+
+export function getCardStatusClass(card: AppCard): string {
+ const type = getCardStatusType(card)
+ const map: Record = {
+ danger: 'app-tag--err',
+ primary: 'app-tag--info',
+ success: 'app-tag--ok',
+ warning: 'app-tag--warn',
+ }
+ return map[type] || 'app-tag--info'
+}
+
+export function getExpireStatusText(card: AppCard) {
+ if (!card.flowPkgEndAt)
+ return '未配置到期时间'
+ const diffDays = getExpireDiffDays(card.flowPkgEndAt)
+ if (diffDays === undefined)
+ return '到期时间异常'
+ if (diffDays < 0)
+ return '已到期'
+ if (diffDays <= 1)
+ return '1天内到期'
+ if (diffDays <= 7)
+ return '7天内到期'
+ if (diffDays <= 30)
+ return '即将到期'
+ return '有效'
+}
+
+export function getExpireStatusType(card: AppCard): 'danger' | 'primary' | 'success' | 'warning' {
+ if (!card.flowPkgEndAt)
+ return 'primary'
+ const diffDays = getExpireDiffDays(card.flowPkgEndAt)
+ if (diffDays === undefined)
+ return 'primary'
+ if (diffDays < 0 || diffDays <= 1)
+ return 'danger'
+ if (diffDays <= 30)
+ return 'warning'
+ return 'success'
+}
+
+export function getCardFilterValue(card: AppCard) {
+ if (
+ card.bossCardStatus === 4
+ || card.bossCardStatus === 8
+ || card.bossCardStatus === 9
+ || card.packageStatus === 3
+ ) {
+ return 'error'
+ }
+ if (isLowFlow(card))
+ return 'low-flow'
+ if (isExpiring(card))
+ return 'expiring'
+ if (card.bossCardStatus === 2)
+ return 'normal'
+ if (typeof card.bossCardStatus === 'number')
+ return 'error'
+ return 'normal'
+}
+
+export function matchesCardFilter(card: AppCard, filter: string) {
+ if (filter === 'all')
+ return true
+ if (filter === 'normal')
+ return card.bossCardStatus === 2 && card.packageStatus !== 3
+ if (filter === 'low-flow')
+ return isLowFlow(card)
+ if (filter === 'expiring')
+ return isExpiring(card)
+ if (filter === 'error') {
+ return (
+ card.packageStatus === 3
+ || card.bossCardStatus === 0
+ || card.bossCardStatus === 4
+ || card.bossCardStatus === 8
+ || card.bossCardStatus === 9
+ || (typeof card.bossCardStatus === 'number' && !(card.bossCardStatus in bossCardStatusTextMap))
+ )
+ }
+ return getCardFilterValue(card) === filter
+}
+
+export function isLowFlow(card: AppCard) {
+ const total = Number(card.packageTotalFlowMb || 0)
+ const remain = Number(card.remainFlowMb || 0)
+ return total > 0 && remain / total <= 0.2
+}
+
+export function isExpiring(card: AppCard) {
+ if (!card.flowPkgEndAt)
+ return false
+ const endTime = new Date(card.flowPkgEndAt).getTime()
+ if (Number.isNaN(endTime))
+ return false
+ const diffDays = (endTime - Date.now()) / 86_400_000
+ return diffDays >= 0 && diffDays <= 30
+}
+
+export function getPackageFilterValue(card: AppCard) {
+ const packageName = card.packageName || card.currentPackageCode || ''
+ if (packageName.includes('加油包'))
+ return 'addon'
+ if (card.packageStatus === 1)
+ return 'pending'
+ if (card.packageStatus === 3)
+ return 'expired'
+ if (isExpiring(card))
+ return 'expiring'
+ return 'active'
+}
+
+export function formatFlow(mb?: number) {
+ const value = Number(mb || 0)
+ if (value >= 1024)
+ return `${trimNumber(value / 1024)}GB`
+ return `${trimNumber(value)}MB`
+}
+
+export function formatRemainFlow(card: AppCard) {
+ return formatFlow(card.remainFlowMb)
+}
+
+export function formatFlowPair(card: AppCard) {
+ return `${formatRemainFlow(card)} / ${formatFlow(card.packageTotalFlowMb)}`
+}
+
+export function formatDateTime(value?: string) {
+ if (!value)
+ return '-'
+ return String(value)
+ .replace('T', ' ')
+ .replace(/\.\d[^+]*$/, '')
+ .replace(/\+.*$/, '')
+}
+
+export function displayCardValue(card?: AppCard) {
+ if (!card)
+ return '-'
+ return card.iccid || card.msisdn || card.imsi || card.imei || '-'
+}
+
+export function displayPackageName(card?: AppCard) {
+ if (!card)
+ return '无套餐'
+ return card.packageName || card.currentPackageCode || '无套餐'
+}
+
+export function getOnlineStatusText(status?: string | number) {
+ const value = normalizeStatusValue(status)
+ if (!value)
+ return '-'
+ const map: Record = {
+ '0': '在线',
+ '00': '在线',
+ '1': '在线',
+ '2': '未知',
+ '-1': '离线',
+ '-2': '未查询到会话',
+ 'offline': '离线',
+ 'online': '在线',
+ 'unknown': '未知',
+ }
+ return map[value.toLowerCase()] || value
+}
+
+export function getImeiBindStatusText(status?: string | number) {
+ const value = normalizeStatusValue(status)
+ if (!value)
+ return '-'
+ const map: Record = {
+ 0: '异常/机卡分离',
+ 1: '正常',
+ normal: '正常',
+ abnormal: '异常/机卡分离',
+ }
+ return map[value.toLowerCase()] || value
+}
+
+export function getRegionLimitStatusText(status?: string | number) {
+ const value = normalizeStatusValue(status)
+ if (!value)
+ return '-'
+ const map: Record = {
+ 0: '未超出业务限制的位置区',
+ 1: '已超出业务限制的位置区',
+ limited: '已超出业务限制的位置区',
+ normal: '未超出业务限制的位置区',
+ }
+ return map[value.toLowerCase()] || value
+}
+
+export function getRegionLimitExceededText(status?: string | number) {
+ const value = normalizeStatusValue(status).toLowerCase()
+ if (!value)
+ return '-'
+ if (value === '1' || value === 'limited' || value === '已超出业务限制的位置区')
+ return '是'
+ if (value === '0' || value === 'normal' || value === '未超出业务限制的位置区')
+ return '否'
+ return value
+}
+
+export function buildSessionSummary(card?: AppCard) {
+ if (!card)
+ return '暂无会话信息'
+ const sessionParts = [
+ getOnlineStatusText(card.onlineStatus),
+ card.apnName || card.apnId,
+ card.ipv4,
+ card.ipv6,
+ ].filter(value => value && value !== '-' && value !== '未知')
+
+ if (sessionParts.length)
+ return sessionParts.join(' · ')
+
+ const fallbackParts = [getNetModelText(card.netModel)].filter(
+ value => value && value !== '-' && value !== '未知',
+ )
+ return fallbackParts.length ? fallbackParts.join(' · ') : '暂无会话信息'
+}
+
+function getExpireDiffDays(value: string) {
+ const endTime = new Date(value).getTime()
+ if (Number.isNaN(endTime))
+ return undefined
+ return (endTime - Date.now()) / 86_400_000
+}
+
+function trimNumber(value: number) {
+ return value.toFixed(value >= 10 ? 1 : 2).replace(/\.?0+$/, '')
+}
+
+function normalizeStatusValue(value?: string | number) {
+ if (value === undefined || value === null)
+ return ''
+ return String(value).trim()
+}
diff --git a/app/app/src/pages/card/index.vue b/app/app/src/pages/card/index.vue
new file mode 100644
index 0000000..0230af6
--- /dev/null
+++ b/app/app/src/pages/card/index.vue
@@ -0,0 +1,1272 @@
+
+
+
+
+
+
+
+
+ {{ cardCount }}
+ 张
+ 名下全部物联卡
+
+
+
+ {{ normalCount }}
+ 正常
+
+
+ {{ lowFlowCount }}
+ 低流量
+
+
+ {{ abnormalCount }}
+ 异常
+
+
+
+
+
+
+
+ 最近查询
+ {{ displayCardValue(lastQueriedCard) }}
+
+
+ {{ getBossCardStatusText(lastQueriedCard.bossCardStatus) }}
+
+
+
+ 到期 {{ formatDateTime(lastQueriedCard.flowPkgEndAt) }}
+
+
+
+
+ {{ getCardAbnormalReasonText(lastQueriedCard) }}
+
+
+
+
+ 详情
+
+
+ 更多
+
+
+
+
+
+
+
+
+
+ 绑定新卡
+ 扫码、拍照或手动输入卡号,快速加入设备列表
+
+
+
+
+ 扫码绑定
+
+
+ 拍照识别
+
+
+ 手输卡号
+
+
+
+
+
+
+
+
+ 我的设备
+
+
+ 共 {{ cardCount }} 张
+ 管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 设备接入
+ 绑定新卡
+
+
+
+ 选择一种方式,把物联卡加入我的设备
+
+
+
+ 1
+ 识别卡片
+
+
+
+ 2
+ 确认信息
+
+
+
+ 3
+ 完成绑定
+
+
+
+
+
+
+
+ 扫码识别
+ 调用扫码读取卡片识别码
+
+ 扫码
+
+
+
+
+ 拍照识别
+ 拍清卡片 ICCID,识别后自动查询并填入
+
+
+
+
+
+ 手动输入
+ 输入 ICCID / MSISDN 等卡号
+
+
+
+
+
+
+ 卡片识别码
+
+
+
+ 绑定
+
+
+
+ 仅绑定当前账号有权限管理的卡片,绑定后可在设备列表查看。
+
+
+
+
+
diff --git a/app/app/src/pages/card/views/api/warning.ts b/app/app/src/pages/card/views/api/warning.ts
new file mode 100644
index 0000000..983d7f2
--- /dev/null
+++ b/app/app/src/pages/card/views/api/warning.ts
@@ -0,0 +1,81 @@
+import type {
+ WarningConfigBatchNotifyParams,
+ WarningConfigBatchNotifyRes,
+ WarningConfigBatchSaveParams,
+ WarningConfigBatchSaveRes,
+ WarningConfigListParams,
+ WarningConfigListRes,
+ WarningConfigMetaRes,
+ WarningConfigNotifyParams,
+ WarningConfigNotifyRes,
+ WarningConfigSaveParams,
+ WarningConfigSaveRes,
+} from '@/api/types/warning'
+import { http } from '@/http/http'
+
+export type {
+ WarningConfig,
+ WarningConfigBatchItemParams,
+ WarningConfigBatchNotifyParams,
+ WarningConfigBatchNotifyRes,
+ WarningConfigBatchSaveParams,
+ WarningConfigBatchSaveRes,
+ WarningConfigListRes,
+ WarningConfigNotifyParams,
+ WarningConfigNotifyRes,
+ WarningConfigSaveParams,
+} from '@/api/types/warning'
+
+export function getWarningConfigList(params: WarningConfigListParams) {
+ return http.get('/warning/config/list', params, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function saveWarningConfig(data: WarningConfigSaveParams) {
+ return http.post('/warning/config/save', data, undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function updateWarningConfigNotify(data: WarningConfigNotifyParams) {
+ return http.post(
+ '/warning/config/notify',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
+
+export function getWarningConfigMeta() {
+ return http.get('/warning/config/meta', undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
+
+export function saveWarningConfigBatch(data: WarningConfigBatchSaveParams) {
+ return http.post(
+ '/warning/config/batch-save',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
+
+export function updateWarningConfigNotifyBatch(data: WarningConfigBatchNotifyParams) {
+ return http.post(
+ '/warning/config/batch-notify',
+ data,
+ undefined,
+ undefined,
+ {
+ hideErrorToast: true,
+ },
+ )
+}
diff --git a/app/app/src/pages/card/views/detail/index.vue b/app/app/src/pages/card/views/detail/index.vue
new file mode 100644
index 0000000..2af4c3d
--- /dev/null
+++ b/app/app/src/pages/card/views/detail/index.vue
@@ -0,0 +1,1200 @@
+
+
+
+
+
+
+
+
+
+ 卡片信息加载中...
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+
+ 卡片概览
+
+ {{
+ isPublicLimited
+ ? '公开查询仅展示卡号、流量和状态'
+ : '默认展示基础信息和流量使用情况'
+ }}
+
+
+
+ {{ getBossCardStatusText(card.bossCardStatus) }}
+
+
+
+
+ 卡号
+
+
+ {{ formatDisplayCardValue(displayCardValue(card)) }}
+
+
+
+
+
+
+
+
+
+
+ 快捷操作
+
+
+
+
+
+
+ 重启
+
+
+
+
+
+ 开通数据
+
+
+
+
+
+ 停用数据
+
+
+
+
+
+
+ 反馈卡片问题
+
+
+
+
+
+
+ {{ section.title }}
+
+
+
+
+
+
+ {{ row[0] }}
+
+
+
+ {{ row[1] }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ section.title }}
+
+
+
+
+
+
+ {{ row[0] }}
+
+
+
+ {{ row[1] }}
+
+
+
+
+
+
+
+
+ {{ V101_RELEASE.showCardMoreActions ? '更多操作' : '划拨这张卡' }}
+
+
+
+ 展示更多信息
+
+
+
+
+
+ 绑定到我的设备
+ 绑定后可在设备页持续查看和办理
+
+
+ 绑定
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/card/views/management/index.vue b/app/app/src/pages/card/views/management/index.vue
new file mode 100644
index 0000000..5ae18b3
--- /dev/null
+++ b/app/app/src/pages/card/views/management/index.vue
@@ -0,0 +1,154 @@
+
+
+
+
+
+
+
+
+
+
+
+ 全部设备
+ 搜索、筛选并处理当前账号可见的物联卡
+
+
+ {{ cardCount }} 张
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/card/views/packageDetail/index.vue b/app/app/src/pages/card/views/packageDetail/index.vue
new file mode 100644
index 0000000..8a5614b
--- /dev/null
+++ b/app/app/src/pages/card/views/packageDetail/index.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ 正在打开卡片详情...
+
+
+
diff --git a/app/app/src/pages/card/views/warning/index.vue b/app/app/src/pages/card/views/warning/index.vue
new file mode 100644
index 0000000..861cf23
--- /dev/null
+++ b/app/app/src/pages/card/views/warning/index.vue
@@ -0,0 +1,404 @@
+
+
+
+
+
+
+
+
+ 预警配置加载中...
+
+
+
+
+
+ {{ errorMessage }}
+
+ 重试
+
+
+
+
+
+
+
+
+
+ {{ pageTitle }}
+
+
+ {{ cardSubTitle }}
+
+
+
+ 站内消息提醒
+
+
+
+
+
+ 剩余流量
+
+ {{ flowText }}
+
+
+
+ 提醒方式
+
+ 全局统一管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 预警阈值
+
+ 第一次提醒必填,第二次和第三次可按需填写
+
+
+
+ 单卡
+
+
+
+
+
+
+
+
+
+
+
+ {{ form.title }}
+
+
+
+ {{ form.desc }}
+
+
+
+
+
+
+
+
+ {{ form.unitText }}
+
+
+
+
+ {{ form.unitText }}
+
+
+
+
+ {{ form.unitText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 已填 {{ filledConfigCount }} 项配置
+ 预警通知在消息中心查看
+
+
+ 保存预警配置
+
+
+
+
diff --git a/app/app/src/pages/home/components/CardOverview.vue b/app/app/src/pages/home/components/CardOverview.vue
new file mode 100644
index 0000000..ec41c58
--- /dev/null
+++ b/app/app/src/pages/home/components/CardOverview.vue
@@ -0,0 +1,344 @@
+
+
+
+
+
+
+
+
+ 我的设备
+
+ {{ cards.length ? '优先关注即将到期的卡片' : '暂无卡片数据' }}
+
+
+
+
+
+ {{ total }} 张
+
+ 管理
+
+
+
+
+
+
+
+
+
+ 设备概览加载失败
+ {{ errorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+
+
+ {{ displayCardValue(item) }}
+ 到期 {{ formatDateTime(item.flowPkgEndAt) }}
+
+
+
+ {{ getBossCardStatusText(item.bossCardStatus) }}
+
+
+
+
+
+
+
+ 绑定后会在这里显示最近卡片和到期信息
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/home/components/FlowPool.vue b/app/app/src/pages/home/components/FlowPool.vue
new file mode 100644
index 0000000..7067a5a
--- /dev/null
+++ b/app/app/src/pages/home/components/FlowPool.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+ 流量池
+
+
+ 查看全部
+
+
+
+
+
+
+
+
+
+ {{ item.name }}
+
+
+ {{ item.cardCount }} 张卡
+
+
+
+
+
+
+
+ 总量 {{ item.total }}
+
+
+ 已用 {{ item.used }}
+
+
+ 剩余 {{ item.remain }}
+
+
+
+
+
+
+
+ 暂无可汇总的套餐流量数据
+
+
+
diff --git a/app/app/src/pages/home/components/FlowRanking.vue b/app/app/src/pages/home/components/FlowRanking.vue
new file mode 100644
index 0000000..4413878
--- /dev/null
+++ b/app/app/src/pages/home/components/FlowRanking.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+ 流量排名
+
+
+
+
+
+
+
+
+
+ {{ index + 1 }}
+
+
+
+ {{ item.flow }}
+
+
+
+
+
+ 暂无流量使用排名
+
+
+
diff --git a/app/app/src/pages/home/components/LifecycleStats.vue b/app/app/src/pages/home/components/LifecycleStats.vue
new file mode 100644
index 0000000..1887ab7
--- /dev/null
+++ b/app/app/src/pages/home/components/LifecycleStats.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+ 生命周期
+
+
+ 卡状态
+
+
+
+
+
+
+
+
+ {{ total }}
+
+
+ 总卡数
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ summaryText }} / 剩余流量 {{ remainFlowText }}
+
+
+
diff --git a/app/app/src/pages/home/components/PublicCardQuery.vue b/app/app/src/pages/home/components/PublicCardQuery.vue
new file mode 100644
index 0000000..53cdac4
--- /dev/null
+++ b/app/app/src/pages/home/components/PublicCardQuery.vue
@@ -0,0 +1,458 @@
+
+
+
+
+
+
+
+
+
+ 卡片查询
+ 输入 / 扫码 / 拍照,快速查看这张卡的状态
+
+ 查询和排查结果仅供参考,以业务系统结果为准
+
+
+
+
+
+
+
+ 查询
+
+
+
+
+
+ 扫码
+
+
+ 拍照
+
+
+ 卡片排查
+
+
+
+
+
+ {{ loadingText }}
+
+
+
+
+ {{ queryErrorMessage }}
+
+
+ 重试
+
+
+
+
+
+
+
+ {{ publicCardTitle(result) }}
+
+
+
+ {{ getBossCardStatusText(result.bossCardStatus) }}
+
+
+
+
+
+ {{ row.label }}
+
+
+ {{ row.value }}
+
+
+
+
+
+
+
+
+
+
+ 查看详情
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/home/components/QuickBusiness.vue b/app/app/src/pages/home/components/QuickBusiness.vue
new file mode 100644
index 0000000..48c557d
--- /dev/null
+++ b/app/app/src/pages/home/components/QuickBusiness.vue
@@ -0,0 +1,318 @@
+
+
+
+
+
+
+
+ 常用入口
+
+ 高频操作
+
+
+
+
+
+
+
+
+ {{ it.label }}
+
+
+
+
+
+
diff --git a/app/app/src/pages/home/index.vue b/app/app/src/pages/home/index.vue
new file mode 100644
index 0000000..8d06687
--- /dev/null
+++ b/app/app/src/pages/home/index.vue
@@ -0,0 +1,266 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 遇到问题?先查卡状态
+
+ 根据卡片状态、在线状态、流量和设备信息,整理常见异常与处理方向
+
+
+ 排查结果仅供参考,以运营商和业务系统结果为准
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/login/forgot-password/index.vue b/app/app/src/pages/login/forgot-password/index.vue
new file mode 100644
index 0000000..2a2d71b
--- /dev/null
+++ b/app/app/src/pages/login/forgot-password/index.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+ 找回密码
+ 短信服务接入后开放
+
+
+
+
+
+
+
+ 找回密码暂未开放
+
+ 当前短信验证码服务还没有接入,为避免提交无效请求,找回密码功能暂时关闭。
+
+
+ 等待短信服务接入
+
+
+ 返回登录
+
+
+
+
+
+
diff --git a/app/app/src/pages/login/index.test.ts b/app/app/src/pages/login/index.test.ts
new file mode 100644
index 0000000..86333d1
--- /dev/null
+++ b/app/app/src/pages/login/index.test.ts
@@ -0,0 +1,125 @@
+import { readFileSync } from 'node:fs'
+import { createContext, runInContext } from 'node:vm'
+import { reactive, ref } from 'vue'
+import { ModuleKind, transpileModule } from 'typescript'
+import { describe, expect, it, vi } from 'vitest'
+import { V101_RELEASE } from '@/constant/release'
+import { navigateAfterLogin, phoneLoginDestination } from '@/utils/loginNavigation'
+
+const home = '/pages/home/index'
+
+function createHarness(redirect = home) {
+ const response = { token: 'test-token', expiresIn: 3600, userInfo: { userId: 1, wechatBound: true } }
+ const login = vi.fn().mockResolvedValue(response)
+ const phoneSmsLogin = vi.fn().mockResolvedValue(response)
+ const setTokenInfo = vi.fn()
+ const setUserInfo = vi.fn()
+ const navigator = { switchTab: vi.fn(), reLaunch: vi.fn(), showToast: vi.fn() }
+ const navigate = vi.fn((...args: Parameters) => {
+ expect(args[3]).not.toBe(navigator)
+ return navigateAfterLogin(...args)
+ })
+ const context = createContext({
+ exports: {},
+ require: (name: string) => {
+ if (name === 'vue')
+ return { reactive, ref, watch: vi.fn(), onUnmounted: vi.fn() }
+ if (name === '@/api/login')
+ return { phoneSmsLogin }
+ if (name === '@/components/SideCaptcha.vue')
+ return {}
+ if (name === '@/store/token')
+ return { useTokenStore: () => ({ login, setTokenInfo }) }
+ if (name === '@/store/user')
+ return { useUserStore: () => ({ userInfo: response.userInfo, setUserInfo, fetchAccessCodes: vi.fn().mockResolvedValue([]) }) }
+ if (name === '@/tabbar/store')
+ return { isPageTabbar: (path: string) => path === home }
+ if (name === '@/tabbar/config')
+ return { isNativeTabbar: false }
+ if (name === '@/utils')
+ return { HOME_PAGE: home }
+ if (name === '@/utils/loginNavigation')
+ return { navigateAfterLogin: navigate, phoneLoginDestination }
+ if (name === '@/constant/release')
+ return { V101_RELEASE }
+ throw new Error(`Unexpected import: ${name}`)
+ },
+ definePage: vi.fn(),
+ onLoad: (callback: (query: { redirect: string }) => void) => callback({ redirect }),
+ uni: navigator,
+ })
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+ const script = source.match(/
+
+
+
+
+
+
+
+
+ 示例企业物联卡
+ 卡片查询 · 状态排查
+ 让每一张物联卡自己会说话
+ 快速掌握卡片状态,查询结果更清晰
+
+
+
+
+ 微信一键登录
+
+
+
+
+ 账号密码登录
+
+
+
+
+ 未登录也可直接查询卡片(输入 ICCID / 扫码)
+
+
+
+
+
+
+
+
+ {{ authMode === 'login' ? '账号登录' : '注册账号' }}
+
+
+ {{
+ authMode === 'login'
+ ? '支持账号密码或手机号验证码登录'
+ : '终端用户可用手机号注册并绑定卡片'
+ }}
+
+
+
+
+
+
+
+
+ 密码登录
+
+
+
+ 验证码登录
+
+
+
+
+
+
+
+
+
+ 微信一键登录
+ 已绑定账号可直接进入,未绑定需验证手机号
+
+
+
+
+
+
+ 或使用账号密码
+
+
+
+
+
+ 账号
+
+
+
+
+ 密码
+
+
+
+
+
+
+ 手机号
+
+
+
+
+ 验证码
+
+
+
+ {{ smsLoading ? '发送中' : smsCountdown.login > 0 ? `${smsCountdown.login}s 后重试` : '获取验证码' }}
+
+
+
+
+
+
+ 登录
+
+
+
+ 注册账号
+ 忘记密码
+
+
+
+
+
+ 未登录也可查卡
+ 输入 ICCID 或扫码查询基础流量信息
+
+
+
+
+
+
+
+
+ 手机号
+
+
+
+
+ 验证码
+
+
+
+ {{ smsLoading ? '发送中' : smsCountdown.register > 0 ? `${smsCountdown.register}s 后重试` : '获取验证码' }}
+
+
+
+
+
+ 昵称
+
+
+
+
+ 密码
+
+
+
+
+ 确认密码
+
+
+
+
+
+ 注册并登录
+
+
+
+ 已有账号,去登录
+ 企业用户使用分配账号
+
+
+
+
+
+ 我已阅读并同意
+
+
+ 《用户服务协议》
+
+
+ 《隐私政策》
+
+
+
+
+
+
+
+
+
+
+ 选择登录账号
+
+ {{
+ accountSelectScene === 'wechatBindPhone'
+ ? '该手机号匹配多个登录身份,请选择要绑定的账号'
+ : '该账号匹配多个登录身份,请选择本次登录账号'
+ }}
+
+
+
+
+
+
+
+
+
+ {{
+ item.nickName || item.username
+ }}
+
+
+ {{ item.username }} · {{ item.deptName || item.role || '账号信息' }}
+
+
+
+ {{ item.role }}
+
+
+
+
+
+ 取消
+
+
+
+
+
+
+
+
+ 绑定手机号
+ 验证手机号后,下次可使用当前微信直接登录对应账号
+
+
+
+
+
+
+ 手机号
+
+
+
+
+ 验证码
+
+
+
+ {{ smsLoading ? '发送中' : smsCountdown.wechatBindPhone > 0 ? `${smsCountdown.wechatBindPhone}s 后重试` : '获取验证码' }}
+
+
+
+
+
+
+ 绑定并登录
+
+
+ 暂不绑定
+
+
+
+
+
+
+
+
+
+
+ 绑定当前微信
+
+ 绑定后可接收流量预警、卡片消息和业务通知,不影响当前账号登录。
+
+
+
+
+
+ {{ item }}
+
+
+
+ 后续可在个人中心的微信绑定入口更换当前订阅微信。
+
+
+
+ 暂不绑定
+
+
+ 立即绑定
+
+
+
+
+
+
+
+
+
+ {{ captchaScene === 'wechatBindPhone' ? '验证绑定手机号' : '请完成安全验证' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/login/register/index.vue b/app/app/src/pages/login/register/index.vue
new file mode 100644
index 0000000..29f9b10
--- /dev/null
+++ b/app/app/src/pages/login/register/index.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/message/components/MessageList.vue b/app/app/src/pages/message/components/MessageList.vue
new file mode 100644
index 0000000..61efdd7
--- /dev/null
+++ b/app/app/src/pages/message/components/MessageList.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+ 未读
+
+
+ {{ item.summary }}
+
+
+ {{ item.typeText || group.label }}
+
+ {{ item.time || '暂无时间' }}
+
+
+
+
+
diff --git a/app/app/src/pages/message/components/MessageTabs.vue b/app/app/src/pages/message/components/MessageTabs.vue
new file mode 100644
index 0000000..1a490ff
--- /dev/null
+++ b/app/app/src/pages/message/components/MessageTabs.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+ {{ g.label }}
+
+ {{ g.unreadCount > 99 ? '99+' : g.unreadCount }}
+
+
+
+
+
+
diff --git a/app/app/src/pages/message/components/MessageToolbar.vue b/app/app/src/pages/message/components/MessageToolbar.vue
new file mode 100644
index 0000000..babfc22
--- /dev/null
+++ b/app/app/src/pages/message/components/MessageToolbar.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ 刷新
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/message/data.test.ts b/app/app/src/pages/message/data.test.ts
new file mode 100644
index 0000000..24df1cf
--- /dev/null
+++ b/app/app/src/pages/message/data.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { compareMessageAsc, compareMessageDesc, normalizeMessage, parseMessageTimestamp } from './data'
+
+describe('message data helpers', () => {
+ it('parses backend and ISO message times', () => {
+ expect(parseMessageTimestamp('2026-07-31 11:21:44')).toBeGreaterThan(0)
+ expect(parseMessageTimestamp('2026-07-31T11:21:44+08:00')).toBeGreaterThan(0)
+ })
+
+ it('sorts messages by effective time desc and id desc', () => {
+ const list = [
+ normalizeMessage({ id: 1, messageId: 1, messageLogId: 1, messageType: 1, title: 'old', content: '', isRead: 0, sendTime: '2026-07-31 11:21:44' }),
+ normalizeMessage({ id: 3, messageId: 3, messageLogId: 3, messageType: 1, title: 'same-new-id', content: '', isRead: 0, sendTime: '2026-08-01T09:00:00+08:00' }),
+ normalizeMessage({ id: 2, messageId: 2, messageLogId: 2, messageType: 1, title: 'same-old-id', content: '', isRead: 0, sendTime: '2026-08-01T09:00:00+08:00' }),
+ ]
+
+ expect(list.sort(compareMessageDesc).map(item => item.messageId)).toEqual([3, 2, 1])
+ })
+
+ it('sorts messages by effective time asc and id asc', () => {
+ const list = [
+ normalizeMessage({ id: 1, messageId: 1, messageLogId: 1, messageType: 1, title: 'old', content: '', isRead: 0, sendTime: '2026-07-31 11:21:44' }),
+ normalizeMessage({ id: 3, messageId: 3, messageLogId: 3, messageType: 1, title: 'same-new-id', content: '', isRead: 0, sendTime: '2026-08-01T09:00:00+08:00' }),
+ normalizeMessage({ id: 2, messageId: 2, messageLogId: 2, messageType: 1, title: 'same-old-id', content: '', isRead: 0, sendTime: '2026-08-01T09:00:00+08:00' }),
+ ]
+
+ expect(list.sort(compareMessageAsc).map(item => item.messageId)).toEqual([1, 2, 3])
+ })
+})
diff --git a/app/app/src/pages/message/data.ts b/app/app/src/pages/message/data.ts
new file mode 100644
index 0000000..85d2d59
--- /dev/null
+++ b/app/app/src/pages/message/data.ts
@@ -0,0 +1,314 @@
+import type { AppMessage } from '@/api/message'
+
+export type MessageGroupKey = 'all' | 'system' | 'platform' | 'order' | 'account'
+export type MessageModuleKey = 'warning' | 'card' | 'order' | 'system'
+export type MessageFilterKey = 'all' | 'recent30' | 'asc'
+export type MessageTagType = 'primary' | 'success' | 'warning' | 'danger'
+
+export interface MessageModule {
+ key: MessageModuleKey
+ label: string
+ icon: string
+ tagType: MessageTagType
+ emptyTip: string
+}
+
+export const messageModules: MessageModule[] = [
+ {
+ key: 'warning',
+ label: '流量预警',
+ icon: 'i-carbon-warning-alt',
+ tagType: 'warning',
+ emptyTip: '暂无流量预警消息',
+ },
+ {
+ key: 'card',
+ label: '卡片消息',
+ icon: 'i-carbon-sim-card',
+ tagType: 'primary',
+ emptyTip: '暂无卡片消息',
+ },
+ {
+ key: 'order',
+ label: '订单通知',
+ icon: 'i-carbon-receipt',
+ tagType: 'success',
+ emptyTip: '暂无订单通知',
+ },
+ {
+ key: 'system',
+ label: '系统通知',
+ icon: 'i-carbon-notification',
+ tagType: 'primary',
+ emptyTip: '暂无系统通知',
+ },
+]
+
+export interface MessageGroup {
+ key: MessageGroupKey
+ label: string
+ messageTypes: number[]
+ unreadCount: number
+ searchPlaceholder: string
+ tag: string
+ tagType: MessageTagType
+ emptyTip: string
+}
+
+export interface MessageFilterOption {
+ key: MessageFilterKey
+ label: string
+}
+
+export interface MessageItem {
+ id: number
+ messageId: number
+ messageLogId: number
+ title: string
+ summary: string
+ content: string
+ time: string
+ unread: boolean
+ messageType: number
+ typeText: string
+ tag: string
+ tagType: MessageTagType
+ raw: AppMessage
+}
+
+export const baseMessageGroups: Omit[] = [
+ {
+ key: 'all',
+ label: '全部',
+ messageTypes: [1, 2, 3, 4],
+ searchPlaceholder: '搜索消息标题或内容',
+ tag: '全部',
+ tagType: 'primary',
+ emptyTip: '暂无消息',
+ },
+ {
+ key: 'system',
+ label: '系统通知',
+ messageTypes: [1],
+ searchPlaceholder: '搜索系统通知',
+ tag: '系统',
+ tagType: 'primary',
+ emptyTip: '暂无系统通知',
+ },
+ {
+ key: 'platform',
+ label: '平台公告',
+ messageTypes: [2],
+ searchPlaceholder: '搜索平台公告',
+ tag: '平台',
+ tagType: 'success',
+ emptyTip: '暂无平台公告',
+ },
+ {
+ key: 'order',
+ label: '订单通知',
+ messageTypes: [3],
+ searchPlaceholder: '搜索订单通知',
+ tag: '订单',
+ tagType: 'warning',
+ emptyTip: '暂无订单通知',
+ },
+ {
+ key: 'account',
+ label: '账户通知',
+ messageTypes: [4],
+ searchPlaceholder: '搜索账户通知',
+ tag: '账户',
+ tagType: 'danger',
+ emptyTip: '暂无账户通知',
+ },
+]
+
+export const messageFilterOptions: MessageFilterOption[] = [
+ { key: 'all', label: '最新优先' },
+ { key: 'recent30', label: '最近30天' },
+ { key: 'asc', label: '时间正序' },
+]
+
+export function normalizeMessage(source: AppMessage): MessageItem {
+ const messageType = Number(source.messageType || 1)
+ const group
+ = baseMessageGroups.find(item => item.messageTypes.includes(messageType))
+ || baseMessageGroups[0]
+ const content = String(source.content || '')
+ const summary = stripRichText(content) || '暂无消息详细内容'
+
+ return {
+ id: Number(source.id || source.messageId || 0),
+ messageId: Number(source.messageId || source.id || 0),
+ messageLogId: Number(source.messageLogId || 0),
+ title: source.title || '未命名消息',
+ summary,
+ content,
+ time: formatMessageTime(source.sendTime || source.createdAt || source.updatedAt || ''),
+ unread: Number(source.isRead) === 0,
+ messageType,
+ typeText: source.messageTypeText || messageTypeText(messageType),
+ tag: group.tag,
+ tagType: messageTypeTagType(messageType),
+ raw: source,
+ }
+}
+
+export function messageMatchesKeyword(item: MessageItem, keyword: string) {
+ const value = keyword.trim()
+ if (!value)
+ return true
+
+ return [item.title, item.summary, item.content, item.time].some(text => text.includes(value))
+}
+
+export function messageMatchesGroup(
+ item: MessageItem,
+ group: Pick,
+) {
+ if (group.key === 'all')
+ return true
+ return group.messageTypes.includes(item.messageType)
+}
+
+const messageModulePatterns: Array<[MessageModuleKey, RegExp]> = [
+ ['warning', /预警|流量|用量|到量|超量|阈值/],
+ ['card', /卡片|停机|复机|重启|物联卡|ICCID|MSISDN|IMSI|IMEI/],
+ ['order', /订单|套餐|续费|充值|支付|账单|购买/],
+ ['system', /系统|平台|维护|公告|活动|账户|账号/],
+]
+
+export function getMessageModuleKey(item: MessageItem): MessageModuleKey {
+ const text = `${item.title}${item.summary}${item.content}`
+ const matched = messageModulePatterns.find(([, pattern]) => pattern.test(text))
+ if (matched)
+ return matched[0]
+
+ switch (item.messageType) {
+ case 3:
+ return 'order'
+ case 1:
+ case 2:
+ case 4:
+ default:
+ return 'system'
+ }
+}
+
+export function messageMatchesModule(item: MessageItem, module: MessageModule) {
+ return getMessageModuleKey(item) === module.key
+}
+
+export function latestModuleMessage(items: MessageItem[], module: MessageModule) {
+ return items.filter(item => messageMatchesModule(item, module)).sort(compareMessageDesc)[0]
+}
+
+export function messageTimestamp(item: MessageItem) {
+ const rawTime = item.raw.sendTime || item.raw.createdAt || item.raw.updatedAt || item.time
+ return parseMessageTimestamp(rawTime)
+}
+
+export function compareMessageDesc(a: MessageItem, b: MessageItem) {
+ const timeDiff = messageTimestamp(b) - messageTimestamp(a)
+ if (timeDiff !== 0)
+ return timeDiff
+ return Number(b.messageId || b.id || 0) - Number(a.messageId || a.id || 0)
+}
+
+export function compareMessageAsc(a: MessageItem, b: MessageItem) {
+ const timeDiff = messageTimestamp(a) - messageTimestamp(b)
+ if (timeDiff !== 0)
+ return timeDiff
+ return Number(a.messageId || a.id || 0) - Number(b.messageId || b.id || 0)
+}
+
+export function parseMessageTimestamp(value?: string | number | Date | null) {
+ if (value === undefined || value === null || value === '')
+ return 0
+ if (value instanceof Date) {
+ const time = value.getTime()
+ return Number.isFinite(time) ? time : 0
+ }
+ if (typeof value === 'number')
+ return Number.isFinite(value) ? value : 0
+
+ const text = String(value).trim()
+ if (!text)
+ return 0
+
+ const candidates = [text, text.replace(' ', 'T'), text.replace(/-/g, '/').replace('T', ' ')]
+
+ for (const candidate of candidates) {
+ const parsed = Date.parse(candidate)
+ if (Number.isFinite(parsed))
+ return parsed
+ }
+
+ return 0
+}
+
+export function messageWithinDays(item: MessageItem, days: number) {
+ if (days <= 0)
+ return true
+
+ const timestamp = messageTimestamp(item)
+ if (!timestamp)
+ return false
+
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
+ return timestamp >= cutoff
+}
+
+function messageTypeText(messageType: number) {
+ switch (messageType) {
+ case 1:
+ return '系统通知'
+ case 2:
+ return '平台公告'
+ case 3:
+ return '订单通知'
+ case 4:
+ return '账户通知'
+ default:
+ return '消息'
+ }
+}
+
+function messageTypeTagType(messageType: number): MessageTagType {
+ switch (messageType) {
+ case 1:
+ return 'danger'
+ case 2:
+ return 'primary'
+ case 3:
+ return 'warning'
+ case 4:
+ return 'success'
+ default:
+ return 'primary'
+ }
+}
+
+export function stripRichText(content: string) {
+ return content
+ .replace(/
diff --git a/app/app/src/pages/user/components/UserServiceCells.vue b/app/app/src/pages/user/components/UserServiceCells.vue
new file mode 100644
index 0000000..f11af1a
--- /dev/null
+++ b/app/app/src/pages/user/components/UserServiceCells.vue
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/components/UserStatsCard.vue b/app/app/src/pages/user/components/UserStatsCard.vue
new file mode 100644
index 0000000..2777aef
--- /dev/null
+++ b/app/app/src/pages/user/components/UserStatsCard.vue
@@ -0,0 +1,218 @@
+
+
+
+
+
+ {{ stats.total }}
+ {{ stats.label }}
+
+
+
+ {{ item.value }}
+ {{ item.label }}
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/components/UserSupportCells.vue b/app/app/src/pages/user/components/UserSupportCells.vue
new file mode 100644
index 0000000..ad6d477
--- /dev/null
+++ b/app/app/src/pages/user/components/UserSupportCells.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/index.test.ts b/app/app/src/pages/user/index.test.ts
new file mode 100644
index 0000000..dce1690
--- /dev/null
+++ b/app/app/src/pages/user/index.test.ts
@@ -0,0 +1,92 @@
+import type { IUserInfoRes } from '@/api/types/login'
+import { readFileSync } from 'node:fs'
+import { createContext, runInContext } from 'node:vm'
+import { ModuleKind, transpileModule } from 'typescript'
+import { describe, expect, it, vi } from 'vitest'
+import RouterPath from '@/constant/RouterPath'
+import { isV101HiddenRoute, V101_RELEASE } from '@/constant/release'
+import * as permission from '@/utils/permission'
+import * as identity from '@/utils/userIdentity'
+
+function createHarness(userInfo: Partial) {
+ const navigateTo = vi.fn()
+ const context = createContext({
+ exports: {},
+ require: (name: string) => {
+ if (name === '@/constant/RouterPath')
+ return { default: RouterPath }
+ if (name === '@/constant/release')
+ return { V101_RELEASE }
+ if (name === '@/utils/permission')
+ return permission
+ if (name === '@/utils/userIdentity')
+ return identity
+ if (name === '@/store/user')
+ return { useUserStore: () => ({ userInfo }) }
+ if (name === '@/store/message')
+ return { useMessageStore: () => ({ refreshUnreadCount: vi.fn() }) }
+ if (name === '@/store/token')
+ return { useTokenStore: () => ({}) }
+ if (name.startsWith('./components/'))
+ return {}
+ throw new Error(`Unexpected import: ${name}`)
+ },
+ computed: (getter: () => unknown) => ({ get value() { return getter() } }),
+ definePage: () => undefined,
+ onShow: () => undefined,
+ uni: { navigateTo, showToast: vi.fn() },
+ })
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+ const script = source.match(/
+
+
+
+
+
+
+
+
+
+
+
+ 业务工作台
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 退出登录
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/about/index.vue b/app/app/src/pages/user/views/about/index.vue
new file mode 100644
index 0000000..edf1bb4
--- /dev/null
+++ b/app/app/src/pages/user/views/about/index.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/agreement/index.vue b/app/app/src/pages/user/views/agreement/index.vue
new file mode 100644
index 0000000..dde9f80
--- /dev/null
+++ b/app/app/src/pages/user/views/agreement/index.vue
@@ -0,0 +1,330 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 协议与隐私
+
+
+ 生效日期:{{ effectiveDate }}。我们仅在提供物联网卡服务所必需的范围内处理你的信息。
+
+
+
+
+ 查看微信隐私保护指引
+
+
+
+
+
+
+
+
+ {{ section.title }}
+
+
+ {{ paragraph }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
+ 涉及信息:{{ item.fields }}
+
+
+ 使用目的:{{ item.purpose }}
+
+
+ 使用场景:{{ item.scene }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ section.title }}
+
+
+ {{ paragraph }}
+
+
+
+
+
+
+
+
+
+
+ 若你不同意相关协议或隐私政策,请停止使用需要登录、绑定或业务办理的功能。
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/api/warning.ts b/app/app/src/pages/user/views/api/warning.ts
new file mode 100644
index 0000000..a97f21b
--- /dev/null
+++ b/app/app/src/pages/user/views/api/warning.ts
@@ -0,0 +1,8 @@
+import type { WarningConfigMetaRes } from '@/api/types/warning'
+import { http } from '@/http/http'
+
+export function getWarningConfigMeta() {
+ return http.get('/warning/config/meta', undefined, undefined, {
+ hideErrorToast: true,
+ })
+}
diff --git a/app/app/src/pages/user/views/contact/index.vue b/app/app/src/pages/user/views/contact/index.vue
new file mode 100644
index 0000000..ac58f6a
--- /dev/null
+++ b/app/app/src/pages/user/views/contact/index.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/executionLog/data.ts b/app/app/src/pages/user/views/executionLog/data.ts
new file mode 100644
index 0000000..588aae7
--- /dev/null
+++ b/app/app/src/pages/user/views/executionLog/data.ts
@@ -0,0 +1,32 @@
+export function executionBatchTypeLabel(type: number) {
+ const labels: Record = {
+ 3: '卡片划拨',
+ 4: '批次准备',
+ 101: '卡片续费',
+ 102: '套餐变更',
+ 103: '开机',
+ 104: '停机',
+ 105: '重启',
+ 109: '开通数据服务',
+ 110: '停用数据服务',
+ }
+ return labels[type] || '其他批次'
+}
+
+export function executionStatusType(status: number) {
+ if (status === 3)
+ return 'success'
+ if (status === 4)
+ return 'danger'
+ if (status === 5)
+ return 'warning'
+ return 'primary'
+}
+
+export function executionDetailStatusType(status: number) {
+ if (status === 2)
+ return 'success'
+ if (status === 3)
+ return 'danger'
+ return 'primary'
+}
diff --git a/app/app/src/pages/user/views/executionLog/index.test.ts b/app/app/src/pages/user/views/executionLog/index.test.ts
new file mode 100644
index 0000000..94ca2d1
--- /dev/null
+++ b/app/app/src/pages/user/views/executionLog/index.test.ts
@@ -0,0 +1,235 @@
+import { readFileSync } from 'node:fs'
+import { createContext, runInContext } from 'node:vm'
+import { ModuleKind, transpileModule } from 'typescript'
+import { describe, expect, it, vi } from 'vitest'
+import * as helpers from './data'
+import * as permission from '@/utils/permission'
+import * as allocateHelpers from '@/pages/business/views/allocate/selection'
+
+function harness(detail = false) {
+ const userInfo = { userId: 12, accessCodes: ['cpm:common:batchExecutionLog:list', 'cpm:common:batchExecutionLog:summary', 'cpm:common:batchExecutionLog:detailList', 'cpm:iot:tSimCardAllocate:targets', 'cpm:iot:tSimCardAllocate:input:prepare', 'cpm:iot:tSimCardAllocate:batch:submit'], appAccess: { configured: true } }
+ const list = vi.fn()
+ const summary = vi.fn()
+ const details = vi.fn()
+ const allocateSummary = vi.fn()
+ const allocateDetails = vi.fn()
+ const hideCallbacks: (() => void)[] = []
+ const unloadCallbacks: (() => void)[] = []
+ const context = createContext({
+ exports: {},
+ require: (name: string) => {
+ if (name === '@/utils/permission')
+ return permission
+ if (name === '@/store/user')
+ return { useUserStore: () => ({ userInfo, fetchUserInfo: vi.fn(async () => userInfo) }) }
+ if (name === '@/utils/allocateStatus')
+ return allocateHelpers
+ if (name === '@/api/executionLog')
+ return { listExecutionLogs: list, executionSummary: summary, executionDetails: details, allocateSummary, allocateDetails, channelLabels: { console: '控制台', api: '开放API' }, modeLabels: ['未知', '单卡', '批量'], inputLabels: ['选择', '连号', '输入', '文件'], statusLabels: ['已创建', '待处理', '处理中', '成功', '失败', '部分成功'] }
+ if (name === '@/constant/RouterPath')
+ return { default: { UserViewsExecutionLogDetail: '/log/detail' } }
+ if (name.endsWith('/data'))
+ return helpers
+ throw new Error(`Unexpected import: ${name}`)
+ },
+ ref: (value: unknown) => ({ value }),
+ computed: (getter: () => unknown) => ({ get value() { return getter() } }),
+ definePage: () => undefined,
+ onShow: () => undefined,
+ onLoad: () => undefined,
+ onHide: (callback: () => void) => hideCallbacks.push(callback),
+ onUnload: (callback: () => void) => unloadCallbacks.push(callback),
+ uni: { navigateTo: vi.fn() },
+ })
+ const source = readFileSync(new URL(detail ? '../executionLogDetail/index.vue' : './index.vue', import.meta.url), 'utf8')
+ const script = source.match(/
+
+
+
+
+
+
+
+ 共 {{ total }} 条执行记录
+
+
+ 重置
+
+
+ {{ filtersOpen ? '收起筛选' : '筛选' }}{{ filterCount ? ` (${filterCount})` : '' }}
+
+
+ 刷新
+
+
+
+ {{ appliedFilterText }}
+
+
+ 发起渠道
+
+ {{ channelLabels[channel[0]] || '全部渠道' }}
+
+
+
+
+ 操作规模
+
+ {{ modeLabels[mode[0]] || '全部操作' }}
+
+
+
+
+ 输入方式
+
+ {{ inputLabels[input[0]] || '全部方式' }}
+
+
+
+
+ 执行状态
+
+ {{ statusLabels[status[0]] || '全部状态' }}
+
+
+
+
+
+
+ {{ error }}
+
+ 重试
+
+
+
+
+
+
+
+
+
+ {{ item.batchNo }}
+
+ {{ statusLabels[item.batchAsyncStatus] || '未知状态' }}
+
+
+
+ {{ executionBatchTypeLabel(item.batchType) }}
+
+
+ {{ channelLabels[item.sourceChannel] || '历史未知' }} · {{ modeLabels[item.executionMode] || '未知操作' }}
+ {{ item.totalCount }} 张卡
+
+
+ {{ allocationUnitName(item.sourceDeptName, item.sourceDeptId) }}
+ →
+ {{ receivingUnitName(item) }}
+
+ {{ item.failReason }}
+
+ {{ item.createdAt || '时间未记录' }}
+
+ 查看详情
+
+
+
+
+
+
+
+ 加载更多
+
+ 已展示全部记录
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/executionLogDetail/index.vue b/app/app/src/pages/user/views/executionLogDetail/index.vue
new file mode 100644
index 0000000..555c16d
--- /dev/null
+++ b/app/app/src/pages/user/views/executionLogDetail/index.vue
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+
+ {{ isAllocateTask ? '划拨任务概览' : '批次概览' }}
+
+ {{ isAllocateTask ? allocatePresentation.title : statusLabels[summary.batchAsyncStatus] || '未知状态' }}
+
+
+
+ 划拨单位:{{ allocationUnitName(summary.sourceDeptName, summary.sourceDeptId) }}
+ 接收单位:{{ receivingUnitName(summary) }}
+
+
+ 任务编号
+ {{ batchNo }}
+
+ 复制
+
+
+
+ {{ summary.batchAsyncStatus === 3 ? '卡片已划拨至目标客户。' : allocatePresentation.description }}
+
+ {{ executionBatchTypeLabel(summary.batchType) }}
+ {{ summary.totalCount }}
+ 张卡 · {{ channelLabels[summary.sourceChannel] || '历史未知' }} · {{ modeLabels[summary.executionMode] || '未知操作' }}
+
+ {{ summary.failReason }}
+
+ {{ summary.createdAt || '时间未记录' }}
+
+ {{ infoOpen ? '收起信息' : '批次信息' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ isAllocateTask ? '卡片明细' : '执行明细' }}
+ {{ total }} 条
+
+
+
+ 仅失败
+
+
+ 刷新
+
+
+
+
+ {{ error }}
+
+ 重试
+
+
+
+
+
+ 第 {{ item.rowNo || index + 1 }} 行
+
+ {{ isAllocateTask ? allocateDetailStatus(item.detailStatus, summary?.batchAsyncStatus) : detailLabels[item.detailStatus] || '未知状态' }}
+
+
+ {{ item.itemId }}
+
+ 划拨单位:{{ allocationUnitName(item.sourceDeptName, item.sourceDeptId) }}
+ 接收单位:{{ receivingUnitName(item) }}
+
+ {{ item.errorReason }}
+
+
+
+
+ 加载更多
+
+ 已展示全部明细
+
+
+
+
diff --git a/app/app/src/pages/user/views/feedback/index.vue b/app/app/src/pages/user/views/feedback/index.vue
new file mode 100644
index 0000000..9e37e0c
--- /dev/null
+++ b/app/app/src/pages/user/views/feedback/index.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/manual/index.vue b/app/app/src/pages/user/views/manual/index.vue
new file mode 100644
index 0000000..e092a89
--- /dev/null
+++ b/app/app/src/pages/user/views/manual/index.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 手册暂时无法打开
+
+ 重新加载
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/notification/data.test.ts b/app/app/src/pages/user/views/notification/data.test.ts
new file mode 100644
index 0000000..f439ee1
--- /dev/null
+++ b/app/app/src/pages/user/views/notification/data.test.ts
@@ -0,0 +1,25 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+describe('站内预警通知入口', () => {
+ it('提醒页仅展示站内消息,不请求微信一次性订阅或保存旧偏好', () => {
+ const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+ expect(source).toContain('站内消息提醒')
+ expect(source).toContain('RouterPath.Message')
+ expect(source).not.toContain('requestWechatSubscribeMessage')
+ expect(source).not.toContain('saveWarningPreference')
+ expect(source).not.toContain('wd-switch')
+ })
+
+ it('卡片和批量预警页面均保留配置入口,消息跳到站内中心', () => {
+ const card = readFileSync(new URL('../../../card/views/warning/index.vue', import.meta.url), 'utf8')
+ const business = readFileSync(new URL('../../../business/views/warning/index.vue', import.meta.url), 'utf8')
+ for (const source of [card, business]) {
+ expect(source).toContain('v-model="form.enabled"')
+ expect(source).toContain('RouterPath.Message')
+ expect(source).toContain('title="消息中心"')
+ expect(source).not.toContain('requestWechatSubscribeMessage')
+ expect(source).not.toContain('微信提醒设置')
+ }
+ })
+})
diff --git a/app/app/src/pages/user/views/notification/index.test.ts b/app/app/src/pages/user/views/notification/index.test.ts
new file mode 100644
index 0000000..e22777d
--- /dev/null
+++ b/app/app/src/pages/user/views/notification/index.test.ts
@@ -0,0 +1,22 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+const notification = readFileSync(new URL('./index.vue', import.meta.url), 'utf8')
+const profile = readFileSync(new URL('../profile/index.vue', import.meta.url), 'utf8')
+
+describe('站内提醒与微信账号绑定职责', () => {
+ it('旧提醒路由保留站内消息入口,不再显示微信订阅开关', () => {
+ expect(notification).toContain('navigationBarTitleText: \'消息提醒\'')
+ expect(notification).toContain('站内消息提醒')
+ expect(notification).toContain('uni.navigateTo({ url: RouterPath.Message })')
+ expect(notification).not.toContain('requestWechatSubscribeMessage')
+ expect(notification).not.toContain('wd-switch')
+ })
+ it('个人资料仍可绑定、更换、解绑微信用于登录', () => {
+ expect(profile).toContain('账号与安全')
+ expect(profile).toContain('更换微信绑定')
+ expect(profile).toContain('解除微信绑定')
+ expect(profile).toContain('await unbindWechat()')
+ expect(profile).toContain('userStore.setUserInfo(res.userInfo)')
+ })
+})
diff --git a/app/app/src/pages/user/views/notification/index.vue b/app/app/src/pages/user/views/notification/index.vue
new file mode 100644
index 0000000..0f27cf8
--- /dev/null
+++ b/app/app/src/pages/user/views/notification/index.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/profile/index.vue b/app/app/src/pages/user/views/profile/index.vue
new file mode 100644
index 0000000..720ff89
--- /dev/null
+++ b/app/app/src/pages/user/views/profile/index.vue
@@ -0,0 +1,555 @@
+
+
+
+
+
+
+
+ {{ avatarText }}
+
+
+
+ {{ displayName }}
+
+
+ 账号 {{ accountText }} · {{ roleText }}
+
+
+
+ {{ canEditProfile ? '取消' : '编辑' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ identityTitle }}
+
+ 资料查看
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 基础资料
+
+ 终端用户
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 单位资料
+
+ 可完善
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 实名与账户
+
+ 自主维护
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 保存资料
+
+
+
+
+
+
+ 账号与安全
+
+
+
+
+
+
+
+
+
+
+ {{ isTerminal ? '联系方式可通过顶部编辑维护。' : '企业资料与联系方式由管理平台维护。' }}预警通知请在消息中心查看。
+
+
+
+ 退出登录
+
+
+
+
+
diff --git a/app/app/src/pages/user/views/realName/index.vue b/app/app/src/pages/user/views/realName/index.vue
new file mode 100644
index 0000000..01346da
--- /dev/null
+++ b/app/app/src/pages/user/views/realName/index.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/router/README.md b/app/app/src/router/README.md
new file mode 100644
index 0000000..60e3084
--- /dev/null
+++ b/app/app/src/router/README.md
@@ -0,0 +1,55 @@
+# 登录 说明
+
+## 登录 2种策略
+- 默认无需登录策略: DEFAULT_NO_NEED_LOGIN
+- 默认需要登录策略: DEFAULT_NEED_LOGIN
+
+### 默认无需登录策略: DEFAULT_NO_NEED_LOGIN
+进入任何页面都不需要登录,只有进入到黑名单中的页面/或者页面中某些动作需要登录,才需要登录。
+
+比如大部分2C的应用,美团、今日头条、抖音等,都可以直接浏览,只有点赞、评论、分享等操作或者去特殊页面(比如个人中心),才需要登录。
+
+### 默认需要登录策略: DEFAULT_NEED_LOGIN
+
+进入任何页面都需要登录,只有进入到白名单中的页面,才不需要登录。默认进入应用需要先去登录页。
+
+比如大部分2B和后台管理类的应用,比如企业微信、钉钉、飞书、内部报表系统、CMS系统等,都需要登录,只有登录后,才能使用。
+
+### EXCLUDE_LOGIN_PATH_LIST
+`EXCLUDE_LOGIN_PATH_LIST` 表示排除的路由列表。
+
+在 `默认无需登录策略: DEFAULT_NO_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才需要登录,相当于黑名单。
+
+在 `默认需要登录策略: DEFAULT_NEED_LOGIN` 中,只有路由在 `EXCLUDE_LOGIN_PATH_LIST` 中,才不需要登录,相当于白名单。
+
+### excludeLoginPath
+definePage 中可以通过 `excludeLoginPath` 来配置路由是否需要登录。(类似过去的 needLogin 的功能)
+
+```ts
+definePage({
+ style: {
+ navigationBarTitleText: '关于',
+ },
+ // 登录授权(可选):跟以前的 needLogin 类似功能,但是同时支持黑白名单,详情请见 src/router 文件夹
+ excludeLoginPath: true,
+ // 角色授权(可选):如果需要根据角色授权,就配置这个
+ roleAuth: {
+ field: 'role',
+ value: 'admin',
+ redirect: '/pages/auth/403',
+ },
+})
+```
+
+## 登录注册页路由
+
+登录页 `login.vue` 对应路由是 `/pages/login/login`.
+注册页 `register.vue` 对应路由是 `/pages/login/register`.
+
+## 登录注册页适用性
+
+登录注册页主要适用于 `h5` 和 `App`,默认不适用于 `小程序`,因为 `小程序` 通常会使用平台提供的快捷登录。
+
+特殊情况例外,如业务需要跨平台复用登录注册页时,也可以用在 `小程序` 上,所以主要还是看业务需求。
+
+通过一个参数 `LOGIN_PAGE_ENABLE_IN_MP` 来控制是否在 `小程序` 中使用 `H5登录页` 的登录逻辑。
diff --git a/app/app/src/router/appPermission.ts b/app/app/src/router/appPermission.ts
new file mode 100644
index 0000000..5f37d82
--- /dev/null
+++ b/app/app/src/router/appPermission.ts
@@ -0,0 +1,26 @@
+import type { AppFeatureInput } from '@/utils/permission'
+
+export const businessPageFeatures: string[] = [
+ 'card:renew',
+ 'card:change',
+ 'card:addon',
+ 'card:allocate',
+ 'warn:view',
+ 'customer:list',
+ 'workorder:list',
+ 'workorder:create',
+]
+
+export const appRouteFeatureMap: Record = {
+ '/pages/business/views/renew/index': 'card:renew',
+ '/pages/business/views/cardRenew/index': 'card:renew',
+ '/pages/business/views/addon/index': 'card:addon',
+ '/pages/business/views/allocate/index': 'card:allocate',
+ '/pages/business/views/change/index': 'card:change',
+ '/pages/business/views/customer/index': 'customer:list',
+ '/pages/business/views/customerDetail/index': 'customer:detail',
+ '/pages/business/views/warning/index': 'warn:view',
+ '/pages/business/views/workOrder/index': ['workorder:list', 'workorder:create'],
+ '/pages/business/views/workOrderCreate/index': 'workorder:create',
+ '/pages/business/views/workOrderDetail/index': 'workorder:detail',
+}
diff --git a/app/app/src/router/config.ts b/app/app/src/router/config.ts
new file mode 100644
index 0000000..b2b687a
--- /dev/null
+++ b/app/app/src/router/config.ts
@@ -0,0 +1,28 @@
+import { getAllPages } from '@/utils'
+
+export const LOGIN_STRATEGY_MAP = {
+ DEFAULT_NO_NEED_LOGIN: 0, // 黑名单策略,默认可以进入APP
+ DEFAULT_NEED_LOGIN: 1, // 白名单策略,默认不可以进入APP,需要强制登录
+}
+// TODO: 1/3 登录策略,默认使用`无需登录策略`,即默认不需要登录就可以访问
+export const LOGIN_STRATEGY = LOGIN_STRATEGY_MAP.DEFAULT_NO_NEED_LOGIN
+export const isNeedLoginMode = LOGIN_STRATEGY === LOGIN_STRATEGY_MAP.DEFAULT_NEED_LOGIN
+
+export const LOGIN_PAGE = '/pages/login/index'
+export const REGISTER_PAGE = '/pages/login/register/index'
+
+export const LOGIN_PAGE_LIST = [LOGIN_PAGE, REGISTER_PAGE]
+
+// 在 definePage 里面配置了 excludeLoginPath 的页面,功能与 EXCLUDE_LOGIN_PATH_LIST 相同
+export const excludeLoginPathList = getAllPages('excludeLoginPath').map(page => page.path)
+
+// 排除在外的列表,白名单策略指白名单列表,黑名单策略指黑名单列表
+// TODO: 2/3 在 definePage 配置 excludeLoginPath,或者在下面配置 EXCLUDE_LOGIN_PATH_LIST
+export const EXCLUDE_LOGIN_PATH_LIST = [
+ '/pages/xxx/index', // 示例值
+ '/pages-sub/xxx/index', // 示例值
+ ...excludeLoginPathList, // 都是以 / 开头的 path
+]
+
+// 小程序端也使用项目内置账号登录页,保证 tabbar 和直接进页面时都能走统一登录拦截
+export const LOGIN_PAGE_ENABLE_IN_MP = true
diff --git a/app/app/src/router/interceptor.ts b/app/app/src/router/interceptor.ts
new file mode 100644
index 0000000..77be614
--- /dev/null
+++ b/app/app/src/router/interceptor.ts
@@ -0,0 +1,173 @@
+import { isMp } from '@uni-helper/uni-env'
+/**
+ * by 菲鸽 on 2025-08-19
+ * 路由拦截,通常也是登录拦截
+ * 黑、白名单的配置,请看 config.ts 文件, EXCLUDE_LOGIN_PATH_LIST
+ */
+import { useTokenStore } from '@/store/token'
+import { useUserStore } from '@/store/user'
+import { hasRouteAccess } from '@/utils/permission'
+import { isPageTabbar, tabbarStore } from '@/tabbar/store'
+import { getAllPages, getLastPage, HOME_PAGE, parseUrlToObj } from '@/utils/index'
+import { EXCLUDE_LOGIN_PATH_LIST, isNeedLoginMode, LOGIN_PAGE, LOGIN_PAGE_ENABLE_IN_MP } from './config'
+import { appRouteFeatureMap } from './appPermission'
+import { isV101HiddenRoute } from '@/constant/release'
+
+export const FG_LOG_ENABLE = false
+
+export function judgeIsExcludePath(path: string) {
+ const isDev = import.meta.env.DEV
+ if (!isDev) {
+ return EXCLUDE_LOGIN_PATH_LIST.includes(path)
+ }
+ const allExcludeLoginPages = getAllPages('excludeLoginPath') // dev 环境下,需要每次都重新获取,否则新配置就不会生效
+ return EXCLUDE_LOGIN_PATH_LIST.includes(path) || (isDev && allExcludeLoginPages.some(page => page.path === path))
+}
+
+export const navigateToInterceptor = {
+ // 注意,这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同
+ // 增加对相对路径的处理,BY 网友 @ideal
+ invoke({ url, query }: { url: string, query?: Record }) {
+ if (url === undefined) {
+ return
+ }
+ let { path, query: _query } = parseUrlToObj(url)
+
+ FG_LOG_ENABLE && console.log('\n\n路由拦截器:-------------------------------------')
+ FG_LOG_ENABLE && console.log('路由拦截器 1: url->', url, ', query ->', query)
+ const myQuery = { ..._query, ...query }
+ // /pages/route-interceptor/index?name=feige&age=30
+ FG_LOG_ENABLE && console.log('路由拦截器 2: path->', path, ', _query ->', _query)
+ FG_LOG_ENABLE && console.log('路由拦截器 3: myQuery ->', myQuery)
+
+ // 处理相对路径
+ if (!path.startsWith('/')) {
+ const currentPath = getLastPage()?.route || ''
+ const normalizedCurrentPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}`
+ const baseDir = normalizedCurrentPath.substring(0, normalizedCurrentPath.lastIndexOf('/'))
+ path = `${baseDir}/${path}`
+ }
+
+ // // 处理路由不存在的情况
+ // if (path !== '/' && !getAllPages().some(page => page.path === path)) {
+ // console.warn('路由不存在:', path)
+ // return false // 明确表示阻止原路由继续执行
+ // }
+
+ // // 插件页面
+ // if (url.startsWith('plugin://')) {
+ // FG_LOG_ENABLE && console.log('路由拦截器 4: plugin:// 路径 ==>', url)
+ // path = url
+ // }
+
+ // 处理直接进入路由非首页时,tabbarIndex 不正确的问题
+ tabbarStore.setAutoCurIdx(path)
+
+ if (isV101HiddenRoute(path)) {
+ uni.showToast({ title: '当前版本暂未开放此功能', icon: 'none' })
+ if (path !== HOME_PAGE)
+ uni.switchTab({ url: HOME_PAGE })
+ return false
+ }
+
+ // 小程序里面使用平台自带的登录,则不走下面的逻辑
+ if (isMp && !LOGIN_PAGE_ENABLE_IN_MP) {
+ return true // 明确表示允许路由继续执行
+ }
+
+ const tokenStore = useTokenStore()
+ FG_LOG_ENABLE && console.log('tokenStore.hasLogin:', tokenStore.hasLogin)
+
+ // 不管黑白名单,登录了就直接去吧(但是当前不能是登录页)
+ if (tokenStore.hasLogin) {
+ if (path !== LOGIN_PAGE) {
+ if (!hasRoutePermission(path)) {
+ uni.showToast({ title: '暂无页面权限', icon: 'none' })
+ return false
+ }
+ return true // 明确表示允许路由继续执行
+ }
+ else {
+ console.log('已经登录,但是还在登录页', myQuery.redirect)
+ const url = myQuery.redirect || HOME_PAGE
+ if (isPageTabbar(url)) {
+ uni.switchTab({ url })
+ }
+ else {
+ uni.navigateTo({ url })
+ }
+ return false // 明确表示阻止原路由继续执行
+ }
+ }
+ let fullPath = path
+
+ if (Object.keys(myQuery).length) {
+ fullPath += `?${Object.keys(myQuery).map(key => `${key}=${myQuery[key]}`).join('&')}`
+ }
+ const redirectUrl = `${LOGIN_PAGE}?redirect=${encodeURIComponent(fullPath)}`
+
+ // #region 1/2 默认需要登录的情况(白名单策略) ---------------------------
+ if (isNeedLoginMode) {
+ // 需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示白名单,可以直接通过
+ if (judgeIsExcludePath(path)) {
+ return true // 明确表示允许路由继续执行
+ }
+ // 否则需要重定向到登录页
+ else {
+ if (path === LOGIN_PAGE) {
+ return true // 明确表示允许路由继续执行
+ }
+ FG_LOG_ENABLE && console.log('1 isNeedLogin(白名单策略) redirectUrl:', redirectUrl)
+ uni.navigateTo({ url: redirectUrl })
+ return false // 明确表示阻止原路由继续执行
+ }
+ }
+ // #endregion 1/2 默认需要登录的情况(白名单策略) ---------------------------
+
+ // #region 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
+ else {
+ // 不需要登录里面的 EXCLUDE_LOGIN_PATH_LIST 表示黑名单,需要重定向到登录页
+ if (judgeIsExcludePath(path)) {
+ FG_LOG_ENABLE && console.log('2 isNeedLogin(黑名单策略) redirectUrl:', redirectUrl)
+ uni.navigateTo({ url: redirectUrl })
+ return false // 修改为false,阻止原路由继续执行
+ }
+ return true // 明确表示允许路由继续执行
+ }
+ // #endregion 2/2 默认不需要登录的情况(黑名单策略) ---------------------------
+ },
+}
+
+function hasRoutePermission(path: string) {
+ const requiredFeature = appRouteFeatureMap[path]
+ if (!requiredFeature)
+ return true
+ const userStore = useUserStore()
+ return hasRouteAccess(userStore.userInfo, path, requiredFeature)
+}
+
+// 针对 chooseLocation 的特殊处理
+export const chooseLocationInterceptor = {
+ invoke(options: any) {
+ // 直接放行 chooseLocation 调用
+ FG_LOG_ENABLE && console.log('chooseLocation 调用,直接放行:', options)
+ return true
+ },
+}
+
+let routeInterceptorInstalled = false
+
+export const routeInterceptor = {
+ install() {
+ if (routeInterceptorInstalled)
+ return
+ routeInterceptorInstalled = true
+ uni.addInterceptor('navigateTo', navigateToInterceptor)
+ uni.addInterceptor('reLaunch', navigateToInterceptor)
+ uni.addInterceptor('redirectTo', navigateToInterceptor)
+ uni.addInterceptor('switchTab', navigateToInterceptor)
+
+ // 添加 chooseLocation 的拦截器,确保直接放行
+ uni.addInterceptor('chooseLocation', chooseLocationInterceptor)
+ },
+}
diff --git a/app/app/src/router/permission.ts b/app/app/src/router/permission.ts
new file mode 100644
index 0000000..06a7bfa
--- /dev/null
+++ b/app/app/src/router/permission.ts
@@ -0,0 +1,26 @@
+import { tabbarStore } from '@/tabbar/store'
+import { useTokenStore } from '@/store/token'
+import { useUserStore } from '@/store/user'
+import { hasRouteAccess } from '@/utils/permission'
+import { appRouteFeatureMap } from './appPermission'
+
+let permissionInstalled = false
+
+export const permission = {
+ install(router) {
+ if (permissionInstalled)
+ return
+ permissionInstalled = true
+ router.beforeEach((to, from, next) => {
+ const path = to.path
+ tabbarStore.setAutoCurIdx(path)
+ const requiredFeature = appRouteFeatureMap[path]
+ if (requiredFeature && useTokenStore().hasLogin && !hasRouteAccess(useUserStore().userInfo, path, requiredFeature)) {
+ uni.showToast({ title: '暂无页面权限', icon: 'none' })
+ next(false)
+ return
+ }
+ next()
+ })
+ },
+}
diff --git a/app/app/src/service/index.ts b/app/app/src/service/index.ts
new file mode 100644
index 0000000..3bfbb5b
--- /dev/null
+++ b/app/app/src/service/index.ts
@@ -0,0 +1,6 @@
+/* eslint-disable */
+// @ts-ignore
+export * from './types';
+
+export * from './listAll';
+export * from './info';
diff --git a/app/app/src/service/info.ts b/app/app/src/service/info.ts
new file mode 100644
index 0000000..fe09da5
--- /dev/null
+++ b/app/app/src/service/info.ts
@@ -0,0 +1,14 @@
+/* eslint-disable */
+// @ts-ignore
+import request from '@/http/vue-query';
+import { CustomRequestOptions_ } from '@/http/types';
+
+import * as API from './types';
+
+/** 用户信息 GET /user/info */
+export function infoUsingGet({ options }: { options?: CustomRequestOptions_ }) {
+ return request('/user/info', {
+ method: 'GET',
+ ...(options || {}),
+ });
+}
diff --git a/app/app/src/service/listAll.ts b/app/app/src/service/listAll.ts
new file mode 100644
index 0000000..bc1c683
--- /dev/null
+++ b/app/app/src/service/listAll.ts
@@ -0,0 +1,18 @@
+/* eslint-disable */
+// @ts-ignore
+import request from '@/http/vue-query';
+import { CustomRequestOptions_ } from '@/http/types';
+
+import * as API from './types';
+
+/** 用户列表 GET /user/listAll */
+export function listAllUsingGet({
+ options,
+}: {
+ options?: CustomRequestOptions_;
+}) {
+ return request('/user/listAll', {
+ method: 'GET',
+ ...(options || {}),
+ });
+}
diff --git a/app/app/src/service/types.ts b/app/app/src/service/types.ts
new file mode 100644
index 0000000..4e46b61
--- /dev/null
+++ b/app/app/src/service/types.ts
@@ -0,0 +1,29 @@
+/* eslint-disable */
+// @ts-ignore
+
+export type InfoUsingGetResponse = {
+ code: number;
+ msg: string;
+ data: UserItem;
+};
+
+export type InfoUsingGetResponses = {
+ 200: InfoUsingGetResponse;
+};
+
+export type ListAllUsingGetResponse = {
+ code: number;
+ msg: string;
+ data: UserItem[];
+};
+
+export type ListAllUsingGetResponses = {
+ 200: ListAllUsingGetResponse;
+};
+
+export type UserItem = {
+ userId: number;
+ username: string;
+ nickname: string;
+ avatar: string;
+};
diff --git a/app/app/src/static/app/icons/1024x1024.png b/app/app/src/static/app/icons/1024x1024.png
new file mode 100644
index 0000000..08dbd5f
Binary files /dev/null and b/app/app/src/static/app/icons/1024x1024.png differ
diff --git a/app/app/src/static/app/icons/120x120.png b/app/app/src/static/app/icons/120x120.png
new file mode 100644
index 0000000..718ca79
Binary files /dev/null and b/app/app/src/static/app/icons/120x120.png differ
diff --git a/app/app/src/static/app/icons/144x144.png b/app/app/src/static/app/icons/144x144.png
new file mode 100644
index 0000000..f78346b
Binary files /dev/null and b/app/app/src/static/app/icons/144x144.png differ
diff --git a/app/app/src/static/app/icons/152x152.png b/app/app/src/static/app/icons/152x152.png
new file mode 100644
index 0000000..f979721
Binary files /dev/null and b/app/app/src/static/app/icons/152x152.png differ
diff --git a/app/app/src/static/app/icons/167x167.png b/app/app/src/static/app/icons/167x167.png
new file mode 100644
index 0000000..d0aef20
Binary files /dev/null and b/app/app/src/static/app/icons/167x167.png differ
diff --git a/app/app/src/static/app/icons/180x180.png b/app/app/src/static/app/icons/180x180.png
new file mode 100644
index 0000000..24bd062
Binary files /dev/null and b/app/app/src/static/app/icons/180x180.png differ
diff --git a/app/app/src/static/app/icons/192x192.png b/app/app/src/static/app/icons/192x192.png
new file mode 100644
index 0000000..a8ea1a2
Binary files /dev/null and b/app/app/src/static/app/icons/192x192.png differ
diff --git a/app/app/src/static/app/icons/20x20.png b/app/app/src/static/app/icons/20x20.png
new file mode 100644
index 0000000..0abed04
Binary files /dev/null and b/app/app/src/static/app/icons/20x20.png differ
diff --git a/app/app/src/static/app/icons/29x29.png b/app/app/src/static/app/icons/29x29.png
new file mode 100644
index 0000000..a20d373
Binary files /dev/null and b/app/app/src/static/app/icons/29x29.png differ
diff --git a/app/app/src/static/app/icons/40x40.png b/app/app/src/static/app/icons/40x40.png
new file mode 100644
index 0000000..2b41be6
Binary files /dev/null and b/app/app/src/static/app/icons/40x40.png differ
diff --git a/app/app/src/static/app/icons/58x58.png b/app/app/src/static/app/icons/58x58.png
new file mode 100644
index 0000000..8e18b42
Binary files /dev/null and b/app/app/src/static/app/icons/58x58.png differ
diff --git a/app/app/src/static/app/icons/60x60.png b/app/app/src/static/app/icons/60x60.png
new file mode 100644
index 0000000..167826b
Binary files /dev/null and b/app/app/src/static/app/icons/60x60.png differ
diff --git a/app/app/src/static/app/icons/72x72.png b/app/app/src/static/app/icons/72x72.png
new file mode 100644
index 0000000..ddb91e3
Binary files /dev/null and b/app/app/src/static/app/icons/72x72.png differ
diff --git a/app/app/src/static/app/icons/76x76.png b/app/app/src/static/app/icons/76x76.png
new file mode 100644
index 0000000..0d9d28e
Binary files /dev/null and b/app/app/src/static/app/icons/76x76.png differ
diff --git a/app/app/src/static/app/icons/80x80.png b/app/app/src/static/app/icons/80x80.png
new file mode 100644
index 0000000..1877042
Binary files /dev/null and b/app/app/src/static/app/icons/80x80.png differ
diff --git a/app/app/src/static/app/icons/87x87.png b/app/app/src/static/app/icons/87x87.png
new file mode 100644
index 0000000..251fb24
Binary files /dev/null and b/app/app/src/static/app/icons/87x87.png differ
diff --git a/app/app/src/static/app/icons/96x96.png b/app/app/src/static/app/icons/96x96.png
new file mode 100644
index 0000000..eccf396
Binary files /dev/null and b/app/app/src/static/app/icons/96x96.png differ
diff --git a/app/app/src/static/fonts/.gitkeep b/app/app/src/static/fonts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/app/app/src/static/images/avatar.jpg b/app/app/src/static/images/avatar.jpg
new file mode 100644
index 0000000..2010a70
Binary files /dev/null and b/app/app/src/static/images/avatar.jpg differ
diff --git a/app/app/src/static/images/default-avatar.png b/app/app/src/static/images/default-avatar.png
new file mode 100644
index 0000000..4eb5879
Binary files /dev/null and b/app/app/src/static/images/default-avatar.png differ
diff --git a/app/app/src/static/images/shandong-yunze-logo.png b/app/app/src/static/images/shandong-yunze-logo.png
new file mode 100644
index 0000000..732fd05
Binary files /dev/null and b/app/app/src/static/images/shandong-yunze-logo.png differ
diff --git a/app/app/src/static/logo.svg b/app/app/src/static/logo.svg
new file mode 100644
index 0000000..eaee669
--- /dev/null
+++ b/app/app/src/static/logo.svg
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/static/my-icons/copyright.svg b/app/app/src/static/my-icons/copyright.svg
new file mode 100644
index 0000000..8e69a8c
--- /dev/null
+++ b/app/app/src/static/my-icons/copyright.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/app/src/static/my-icons/sparkle.svg b/app/app/src/static/my-icons/sparkle.svg
new file mode 100644
index 0000000..14eccc1
--- /dev/null
+++ b/app/app/src/static/my-icons/sparkle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/app/src/static/tabbar/example.png b/app/app/src/static/tabbar/example.png
new file mode 100644
index 0000000..fd1e942
Binary files /dev/null and b/app/app/src/static/tabbar/example.png differ
diff --git a/app/app/src/static/tabbar/exampleHL.png b/app/app/src/static/tabbar/exampleHL.png
new file mode 100644
index 0000000..7501011
Binary files /dev/null and b/app/app/src/static/tabbar/exampleHL.png differ
diff --git a/app/app/src/static/tabbar/home.png b/app/app/src/static/tabbar/home.png
new file mode 100644
index 0000000..8f82e21
Binary files /dev/null and b/app/app/src/static/tabbar/home.png differ
diff --git a/app/app/src/static/tabbar/homeHL.png b/app/app/src/static/tabbar/homeHL.png
new file mode 100644
index 0000000..26d3761
Binary files /dev/null and b/app/app/src/static/tabbar/homeHL.png differ
diff --git a/app/app/src/static/tabbar/personal.png b/app/app/src/static/tabbar/personal.png
new file mode 100644
index 0000000..0a569a2
Binary files /dev/null and b/app/app/src/static/tabbar/personal.png differ
diff --git a/app/app/src/static/tabbar/personalHL.png b/app/app/src/static/tabbar/personalHL.png
new file mode 100644
index 0000000..8c3e66e
Binary files /dev/null and b/app/app/src/static/tabbar/personalHL.png differ
diff --git a/app/app/src/static/tabbar/scan.png b/app/app/src/static/tabbar/scan.png
new file mode 100644
index 0000000..f0f60c2
Binary files /dev/null and b/app/app/src/static/tabbar/scan.png differ
diff --git a/app/app/src/store/index.ts b/app/app/src/store/index.ts
new file mode 100644
index 0000000..e490556
--- /dev/null
+++ b/app/app/src/store/index.ts
@@ -0,0 +1,22 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { createPersistedState } from 'pinia-plugin-persistedstate' // 数据持久化
+
+const store = createPinia()
+store.use(
+ createPersistedState({
+ storage: {
+ getItem: uni.getStorageSync,
+ setItem: uni.setStorageSync,
+ },
+ }),
+)
+// 立即激活 Pinia 实例, 这样即使在 app.use(store)之前调用 store 也能正常工作 (解决APP端白屏问题)
+setActivePinia(store)
+
+export default store
+
+export * from './lastQueriedCard'
+export * from './message'
+// 模块统一导出
+export * from './token'
+export * from './user'
diff --git a/app/app/src/store/lastQueriedCard.ts b/app/app/src/store/lastQueriedCard.ts
new file mode 100644
index 0000000..00c9568
--- /dev/null
+++ b/app/app/src/store/lastQueriedCard.ts
@@ -0,0 +1,36 @@
+import type { AppCard } from '@/api/card'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+
+export const useLastQueriedCardStore = defineStore(
+ 'lastQueriedCard',
+ () => {
+ const card = ref()
+ const queriedAt = ref(0)
+
+ const hasCard = computed(() => Boolean(card.value))
+
+ function setCard(value?: AppCard) {
+ card.value = value
+ queriedAt.value = value ? Date.now() : 0
+ }
+
+ function clearCard() {
+ setCard()
+ }
+
+ return {
+ card,
+ clearCard,
+ hasCard,
+ queriedAt,
+ setCard,
+ }
+ },
+ {
+ persist: {
+ key: 'iotlcm-last-queried-card',
+ paths: ['card', 'queriedAt'],
+ },
+ },
+)
diff --git a/app/app/src/store/message.test.ts b/app/app/src/store/message.test.ts
new file mode 100644
index 0000000..6ccb52d
--- /dev/null
+++ b/app/app/src/store/message.test.ts
@@ -0,0 +1,54 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { nextTick, reactive } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useMessageStore } from './message'
+
+const mocks = vi.hoisted(() => ({ fetch: vi.fn(), token: { validToken: 'account-a', hasLogin: true, updateNowTime: () => mocks.token } }))
+vi.mock('@/api/message', () => ({ getMessageCenter: mocks.fetch }))
+vi.mock('./token', () => ({ useTokenStore: () => mocks.token }))
+vi.mock('@/tabbar/store', () => ({ tabbarList: { value: [] }, tabbarStore: { setTabbarItemBadge: vi.fn() } }))
+
+beforeEach(() => {
+ setActivePinia(createPinia())
+ mocks.fetch.mockReset()
+ mocks.token = reactive({ validToken: 'account-a', hasLogin: true, updateNowTime: () => mocks.token })
+})
+
+describe('未读消息账号隔离', () => {
+ it('同一账号的并发刷新合并为一次请求', async () => {
+ mocks.fetch.mockResolvedValue({ totalUnread: 3 })
+ const store = useMessageStore()
+ await Promise.all([store.refreshUnreadCount(), store.refreshUnreadCount()])
+ expect(mocks.fetch).toHaveBeenCalledTimes(1)
+ expect(store.unreadCount).toBe(3)
+ })
+ it('退出后旧请求不能写回未读数量', async () => {
+ let complete: (value: { totalUnread: number }) => void = () => {}
+ mocks.fetch.mockImplementation(() => new Promise((resolve) => {
+ complete = resolve
+ }))
+ const store = useMessageStore()
+ const pending = store.refreshUnreadCount()
+ mocks.token.validToken = ''
+ mocks.token.hasLogin = false
+ await nextTick()
+ complete({ totalUnread: 9 })
+ await pending
+ expect(store.unreadCount).toBe(0)
+ })
+ it('切换账号不会复用旧请求或覆盖新账号结果', async () => {
+ let complete: (value: { totalUnread: number }) => void = () => {}
+ mocks.fetch.mockImplementationOnce(() => new Promise((resolve) => {
+ complete = resolve
+ })).mockResolvedValueOnce({ totalUnread: 2 })
+ const store = useMessageStore()
+ const oldRequest = store.refreshUnreadCount()
+ mocks.token.validToken = 'account-b'
+ await nextTick()
+ await store.refreshUnreadCount()
+ complete({ totalUnread: 8 })
+ await oldRequest
+ expect(store.unreadCount).toBe(2)
+ expect(mocks.fetch).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/app/app/src/store/message.ts b/app/app/src/store/message.ts
new file mode 100644
index 0000000..e88c8fd
--- /dev/null
+++ b/app/app/src/store/message.ts
@@ -0,0 +1,117 @@
+import { getMessageCenter } from '@/api/message'
+import RouterPath from '@/constant/RouterPath'
+import { tabbarList, tabbarStore } from '@/tabbar/store'
+import { defineStore } from 'pinia'
+import { computed, ref, watch } from 'vue'
+import { useTokenStore } from './token'
+
+function normalizeUnreadCount(value: number) {
+ const count = Number(value)
+ if (!Number.isFinite(count))
+ return 0
+ return Math.max(0, Math.floor(count))
+}
+
+function getMessageTabbarIndex() {
+ return tabbarList.value.findIndex(item => item.pagePath === RouterPath.Message)
+}
+
+export const useMessageStore = defineStore(
+ 'message',
+ () => {
+ const tokenStore = useTokenStore()
+ const unreadCount = ref(0)
+ const lastSyncedAt = ref(0)
+ const syncing = ref(false)
+ let refreshTask: Promise | null = null
+ let refreshToken = ''
+ let requestVersion = 0
+
+ const hasUnread = computed(() => unreadCount.value > 0)
+ const tabbarBadge = computed(() => (unreadCount.value > 99 ? 100 : unreadCount.value))
+
+ function syncTabbarBadge() {
+ const index = getMessageTabbarIndex()
+ if (index < 0)
+ return
+ tabbarStore.setTabbarItemBadge(index, tabbarBadge.value)
+ }
+
+ function setUnreadCount(value: number) {
+ unreadCount.value = normalizeUnreadCount(value)
+ lastSyncedAt.value = Date.now()
+ syncTabbarBadge()
+ return unreadCount.value
+ }
+
+ function adjustUnreadCount(delta: number) {
+ const next = Number(delta)
+ return setUnreadCount(unreadCount.value + (Number.isFinite(next) ? next : 0))
+ }
+
+ function resetUnreadCount() {
+ return setUnreadCount(0)
+ }
+
+ watch(
+ () => tokenStore.validToken,
+ () => {
+ requestVersion++
+ refreshTask = null
+ syncing.value = false
+ resetUnreadCount()
+ },
+ { immediate: true, flush: 'sync' },
+ )
+
+ async function refreshUnreadCount() {
+ if (!tokenStore.updateNowTime().hasLogin) {
+ resetUnreadCount()
+ return 0
+ }
+
+ const token = tokenStore.validToken
+ if (refreshTask && refreshToken === token)
+ return refreshTask
+
+ refreshToken = token
+ const version = ++requestVersion
+ syncing.value = true
+ refreshTask = (async () => {
+ try {
+ const res = await getMessageCenter({
+ page: 1,
+ pageSize: 1,
+ })
+ if (version !== requestVersion || tokenStore.validToken !== token)
+ return unreadCount.value
+ return setUnreadCount(Number(res.totalUnread || 0))
+ }
+ catch (error) {
+ console.error('刷新消息未读数失败:', error)
+ syncTabbarBadge()
+ return unreadCount.value
+ }
+ finally {
+ if (version === requestVersion) {
+ syncing.value = false
+ refreshTask = null
+ }
+ }
+ })()
+
+ return refreshTask
+ }
+
+ return {
+ adjustUnreadCount,
+ hasUnread,
+ lastSyncedAt,
+ resetUnreadCount,
+ refreshUnreadCount,
+ setUnreadCount,
+ syncing,
+ unreadCount,
+ }
+ },
+)
diff --git a/app/app/src/store/token.ts b/app/app/src/store/token.ts
new file mode 100644
index 0000000..ca4f55b
--- /dev/null
+++ b/app/app/src/store/token.ts
@@ -0,0 +1,318 @@
+import type { ILoginForm } from '@/api/login'
+import type { IAuthLoginRes, IDoubleTokenRes, ISingleTokenRes } from '@/api/types/login'
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue' // 修复:导入 computed
+import {
+ login as _login,
+ logout as _logout,
+ refreshToken as _refreshToken,
+} from '@/api/login'
+import { getHttpErrorMessage } from '@/http/tools/enum'
+import { useUserStore } from './user'
+
+function isSingleTokenResponse(tokenRes: unknown): tokenRes is ISingleTokenRes {
+ return Boolean(tokenRes)
+ && typeof tokenRes === 'object'
+ && typeof (tokenRes as ISingleTokenRes).token === 'string'
+ && !('refreshToken' in tokenRes)
+}
+
+function isDoubleTokenResponse(tokenRes: unknown): tokenRes is IDoubleTokenRes {
+ return Boolean(tokenRes)
+ && typeof tokenRes === 'object'
+ && typeof (tokenRes as IDoubleTokenRes).accessToken === 'string'
+ && typeof (tokenRes as IDoubleTokenRes).refreshToken === 'string'
+}
+
+function isLoginSelectResponse(tokenRes: unknown): tokenRes is { needSelect: true } {
+ return Boolean(tokenRes)
+ && typeof tokenRes === 'object'
+ && (tokenRes as { needSelect?: unknown }).needSelect === true
+}
+
+function getLoginErrorMessage(error: unknown) {
+ return getHttpErrorMessage(error, '登录失败,请重试')
+}
+
+/**
+ * 是否是双token模式
+ */
+export const isDoubleTokenMode = import.meta.env.VITE_AUTH_MODE === 'double'
+// 初始化状态
+const tokenInfoState = isDoubleTokenMode
+ ? {
+ accessToken: '',
+ accessExpiresIn: 0,
+ refreshToken: '',
+ refreshExpiresIn: 0,
+ }
+ : {
+ token: '',
+ expiresIn: 0,
+ }
+
+export const useTokenStore = defineStore(
+ 'token',
+ () => {
+ // 定义用户信息
+ const tokenInfo = ref({ ...tokenInfoState })
+
+ // 添加一个时间戳 ref 作为响应式依赖
+ const nowTime = ref(Date.now())
+ /**
+ * 更新响应式数据:now
+ * 确保isTokenExpired/isRefreshTokenExpired重新计算,而不是用错误过期缓存值
+ * 可useTokenStore内部适时调用;也可链式调用:tokenStore.updateNowTime().hasLogin
+ * @returns 最新的tokenStore实例
+ */
+ const updateNowTime = () => {
+ nowTime.value = Date.now()
+ return useTokenStore()
+ }
+
+ // 设置用户信息
+ const setTokenInfo = (val: IAuthLoginRes) => {
+ updateNowTime()
+ tokenInfo.value = val
+
+ // 计算并存储过期时间
+ const now = Date.now()
+ if (isSingleTokenResponse(val)) {
+ // 单token模式
+ const expireTime = now + val.expiresIn * 1000
+ uni.setStorageSync('accessTokenExpireTime', expireTime)
+ }
+ else if (isDoubleTokenResponse(val)) {
+ // 双token模式
+ const accessExpireTime = now + val.accessExpiresIn * 1000
+ const refreshExpireTime = now + val.refreshExpiresIn * 1000
+ uni.setStorageSync('accessTokenExpireTime', accessExpireTime)
+ uni.setStorageSync('refreshTokenExpireTime', refreshExpireTime)
+ }
+ }
+
+ /**
+ * 判断token是否过期
+ */
+ const isTokenExpired = computed(() => {
+ if (!tokenInfo.value) {
+ return true
+ }
+
+ const now = nowTime.value
+ const expireTime = uni.getStorageSync('accessTokenExpireTime')
+
+ if (!expireTime)
+ return true
+ return now >= expireTime
+ })
+
+ /**
+ * 判断refreshToken是否过期
+ */
+ const isRefreshTokenExpired = computed(() => {
+ if (!isDoubleTokenMode)
+ return true
+
+ const now = nowTime.value
+ const refreshExpireTime = uni.getStorageSync('refreshTokenExpireTime')
+
+ if (!refreshExpireTime)
+ return true
+ return now >= refreshExpireTime
+ })
+
+ /**
+ * 登录成功后处理逻辑
+ * @param tokenInfo 登录返回的token信息
+ */
+ async function _postLogin(tokenInfo: IAuthLoginRes) {
+ setTokenInfo(tokenInfo)
+ const userStore = useUserStore()
+ if (isSingleTokenResponse(tokenInfo) && tokenInfo.userInfo) {
+ userStore.setUserInfo(tokenInfo.userInfo)
+ void userStore.fetchAccessCodes(false).catch(() => undefined)
+ }
+ else {
+ await userStore.fetchUserInfo()
+ }
+ }
+
+ /**
+ * 用户登录
+ * 有的时候后端会用一个接口返回token和用户信息,有的时候会分开2个接口,一个获取token,一个获取用户信息
+ * (各有利弊,看业务场景和系统复杂度),这里使用2个接口返回的来模拟
+ * @param loginForm 登录参数
+ * @returns 登录结果
+ */
+ const login = async (loginForm: ILoginForm) => {
+ try {
+ const res = await _login(loginForm)
+ console.log('普通登录-res: ', res)
+ if (isLoginSelectResponse(res)) {
+ return res
+ }
+ await _postLogin(res)
+ uni.showToast({
+ title: '登录成功',
+ icon: 'success',
+ })
+ return res
+ }
+ catch (error) {
+ console.error('登录失败:', error)
+ uni.showToast({
+ title: getLoginErrorMessage(error),
+ icon: 'none',
+ })
+ throw error
+ }
+ finally {
+ updateNowTime()
+ }
+ }
+
+ /**
+ * 退出登录 并 删除用户信息
+ */
+ const logout = async () => {
+ try {
+ // 待办:实现自己的退出登录逻辑
+ await _logout()
+ }
+ catch (error) {
+ console.error('退出登录失败:', error)
+ }
+ finally {
+ updateNowTime()
+
+ // 无论成功失败,都需要清除本地token信息
+ // 清除存储的过期时间
+ uni.removeStorageSync('accessTokenExpireTime')
+ uni.removeStorageSync('refreshTokenExpireTime')
+ console.log('退出登录-清除用户信息')
+ tokenInfo.value = { ...tokenInfoState }
+ uni.removeStorageSync('token')
+ const userStore = useUserStore()
+ userStore.clearUserInfo()
+ }
+ }
+
+ /**
+ * 刷新token
+ * @returns 刷新结果
+ */
+ const refreshToken = async () => {
+ if (!isDoubleTokenMode) {
+ console.error('单token模式不支持刷新token')
+ throw new Error('单token模式不支持刷新token')
+ }
+
+ try {
+ // 安全检查,确保refreshToken存在
+ if (!isDoubleTokenResponse(tokenInfo.value) || !tokenInfo.value.refreshToken) {
+ throw new Error('无效的refreshToken')
+ }
+
+ const refreshToken = tokenInfo.value.refreshToken
+ const res = await _refreshToken(refreshToken)
+ console.log('刷新token-res: ', res)
+ setTokenInfo(res)
+ return res
+ }
+ catch (error) {
+ console.error('刷新token失败:', error)
+ throw error
+ }
+ finally {
+ updateNowTime()
+ }
+ }
+
+ /**
+ * 获取有效的token
+ * 注意:在computed中不直接调用异步函数,只做状态判断
+ * 实际的刷新操作应由调用方处理
+ * 建议这样使用 tokenStore.updateNowTime().validToken
+ */
+ const getValidToken = computed(() => {
+ // 登录凭证已过期,返回空
+ if (isTokenExpired.value) {
+ return ''
+ }
+
+ if (!isDoubleTokenMode) {
+ return isSingleTokenResponse(tokenInfo.value) ? tokenInfo.value.token : ''
+ }
+ else {
+ return isDoubleTokenResponse(tokenInfo.value) ? tokenInfo.value.accessToken : ''
+ }
+ })
+
+ /**
+ * 检查是否有登录信息(不考虑token是否过期)
+ */
+ const hasLoginInfo = computed(() => {
+ if (!tokenInfo.value) {
+ return false
+ }
+ if (isDoubleTokenMode) {
+ return isDoubleTokenResponse(tokenInfo.value) && !!tokenInfo.value.accessToken
+ }
+ else {
+ return isSingleTokenResponse(tokenInfo.value) && !!tokenInfo.value.token
+ }
+ })
+
+ /**
+ * 检查是否已登录且token有效
+ * 建议这样使用tokenStore.updateNowTime().hasLogin
+ */
+ const hasValidLogin = computed(() => {
+ console.log('hasValidLogin', hasLoginInfo.value, !isTokenExpired.value)
+ return hasLoginInfo.value && !isTokenExpired.value
+ })
+
+ /**
+ * 尝试获取有效的token,如果过期且可刷新,则刷新token
+ * @returns 有效的token或空字符串
+ */
+ const tryGetValidToken = async (): Promise => {
+ updateNowTime()
+ if (!getValidToken.value && isDoubleTokenMode && !isRefreshTokenExpired.value) {
+ try {
+ await refreshToken()
+ return getValidToken.value
+ }
+ catch (error) {
+ console.error('尝试刷新token失败:', error)
+ return ''
+ }
+ }
+ return getValidToken.value
+ }
+
+ return {
+ // 核心API方法
+ login,
+ logout,
+
+ // 认证状态判断(最常用的)
+ hasLogin: hasValidLogin,
+
+ // 内部系统使用的方法
+ refreshToken,
+ tryGetValidToken,
+ validToken: getValidToken,
+
+ // 调试或特殊场景可能需要直接访问的信息
+ tokenInfo,
+ setTokenInfo,
+ updateNowTime,
+ }
+ },
+ {
+ // 添加持久化配置,确保刷新页面后token信息不丢失
+ persist: true,
+ },
+)
diff --git a/app/app/src/store/user.test.ts b/app/app/src/store/user.test.ts
new file mode 100644
index 0000000..3fea141
--- /dev/null
+++ b/app/app/src/store/user.test.ts
@@ -0,0 +1,36 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { getUserInfo } from '@/api/login'
+import { getAccessCodes } from '@/api/system'
+import { hasUserPermission } from '@/utils/permission'
+import { useUserStore } from './user'
+
+vi.mock('@/api/login', () => ({ getUserInfo: vi.fn() }))
+vi.mock('@/api/system', () => ({ getAccessCodes: vi.fn() }))
+const allocateCodes = ['cpm:iot:tSimCardAllocate:targets', 'cpm:iot:tSimCardAllocate:input:prepare', 'cpm:iot:tSimCardAllocate:batch:submit']
+beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.resetAllMocks()
+ vi.mocked(getUserInfo).mockResolvedValue({ userId: 12, username: 'agent', deptId: 100, userType: 'sys_user', appAccess: { configured: true, features: ['card:allocate'], permissions: allocateCodes } } as any)
+})
+describe('current console grants', () => {
+ it('revokes stale feature grants and restores newly granted permissions', async () => {
+ const store = useUserStore()
+ vi.mocked(getAccessCodes).mockResolvedValue(allocateCodes)
+ await store.fetchUserInfo()
+ expect(hasUserPermission(store.userInfo, 'allocate')).toBe(true)
+ vi.mocked(getAccessCodes).mockResolvedValue(['cpc:current:user'])
+ await store.fetchUserInfo()
+ expect(hasUserPermission(store.userInfo, 'allocate')).toBe(false)
+ vi.mocked(getAccessCodes).mockResolvedValue(allocateCodes)
+ await store.fetchUserInfo()
+ expect(hasUserPermission(store.userInfo, 'allocate')).toBe(true)
+ })
+ it('fails closed on permission refresh failure', async () => {
+ const store = useUserStore()
+ store.setAccessCodes(allocateCodes)
+ vi.mocked(getAccessCodes).mockRejectedValue(new Error('offline'))
+ await expect(store.fetchUserInfo()).rejects.toThrow('offline')
+ expect(hasUserPermission(store.userInfo, 'allocate')).toBe(false)
+ })
+})
diff --git a/app/app/src/store/user.ts b/app/app/src/store/user.ts
new file mode 100644
index 0000000..5aa810b
--- /dev/null
+++ b/app/app/src/store/user.ts
@@ -0,0 +1,149 @@
+import type { IUserInfoRes } from '@/api/types/login'
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { getUserInfo } from '@/api/login'
+import { getAccessCodes } from '@/api/system'
+
+const userInfoState: IUserInfoRes = {
+ userId: -1,
+ username: '',
+ nickName: '',
+ nickname: '',
+ phone: '',
+ userType: '',
+ isTerminal: false,
+ canEnterpriseBusiness: false,
+ wechatBound: false,
+ wechatBindText: '未绑定',
+ role: '企业用户',
+ sex: '2',
+ balance: 0,
+ avatar: '/static/images/default-avatar.png',
+ accessCodes: [],
+}
+
+function normalizeAccessCodes(codes?: readonly string[] | null) {
+ return Array.from(
+ new Set(
+ (codes || [])
+ .map(code => String(code || '').trim())
+ .filter(Boolean),
+ ),
+ )
+}
+
+function normalizeUserRole(role: string | undefined, isTerminal: boolean) {
+ if (isTerminal)
+ return '终端用户'
+ return role || '企业用户'
+}
+
+function normalizeUserInfo(val: IUserInfoRes, fallbackAccessCodes: string[] = []): IUserInfoRes {
+ const userType = String(val.userType || '').trim()
+ const isTerminal = typeof val.isTerminal === 'boolean'
+ ? val.isTerminal
+ : userType === 'app_user' || userType === 'terminal'
+ const deptId = Number(val.deptId || val.enterpriseProfile?.deptId || 0)
+ const accessCodes = normalizeAccessCodes(
+ Array.isArray(val.accessCodes) ? val.accessCodes : fallbackAccessCodes,
+ )
+
+ return {
+ ...val,
+ deptId,
+ isTerminal,
+ userType,
+ canEnterpriseBusiness: Boolean(val.canEnterpriseBusiness) || (!isTerminal && deptId > 0),
+ wechatBound: Boolean(val.wechatBound),
+ wechatBindText: val.wechatBindText || (val.wechatBound ? '已绑定' : '未绑定'),
+ role: normalizeUserRole(val.role, isTerminal),
+ avatar: val.avatar || userInfoState.avatar,
+ nickname: val.nickname || val.nickName,
+ accessCodes,
+ }
+}
+
+export const useUserStore = defineStore(
+ 'user',
+ () => {
+ const userInfo = ref({ ...userInfoState })
+ let fetchUserInfoPromise: Promise | undefined
+
+ const setUserInfo = (val: IUserInfoRes) => {
+ const previousUserId = Number(userInfo.value.userId || 0)
+ const nextUserId = Number(val.userId || 0)
+ const fallbackAccessCodes = previousUserId > 0 && previousUserId === nextUserId
+ ? userInfo.value.accessCodes
+ : []
+ userInfo.value = normalizeUserInfo(val, fallbackAccessCodes)
+ }
+
+ const setAccessCodes = (codes?: readonly string[] | null) => {
+ const accessCodes = normalizeAccessCodes(codes)
+ userInfo.value.accessCodes = accessCodes
+ userInfo.value.appAccess = {
+ configured: true,
+ permissions: accessCodes,
+ features: [],
+ routes: [],
+ menus: [],
+ }
+ }
+
+ const setUserAvatar = (avatar: string) => {
+ userInfo.value.avatar = avatar
+ }
+
+ const clearUserInfo = () => {
+ userInfo.value = { ...userInfoState }
+ uni.removeStorageSync('user')
+ }
+
+ const fetchAccessCodes = async (isCache = false) => {
+ const codes = await getAccessCodes({ isCache })
+ setAccessCodes(codes)
+ return userInfo.value.accessCodes || []
+ }
+
+ const fetchUserInfo = async () => {
+ if (fetchUserInfoPromise)
+ return fetchUserInfoPromise
+
+ fetchUserInfoPromise = (async () => {
+ const res = await getUserInfo()
+ // 先获取当前权限,避免旧的功能授权覆盖控制台撤销结果。
+ let accessCodes: string[]
+ try {
+ accessCodes = await getAccessCodes({ isCache: false })
+ }
+ catch (error) {
+ setAccessCodes([])
+ throw error
+ }
+ setUserInfo({ ...res, accessCodes })
+ setAccessCodes(accessCodes)
+ return userInfo.value
+ })()
+
+ try {
+ return await fetchUserInfoPromise
+ }
+ finally {
+ fetchUserInfoPromise = undefined
+ }
+ }
+
+ return {
+ userInfo,
+ clearUserInfo,
+ fetchAccessCodes,
+ fetchUserInfo,
+ setAccessCodes,
+ setUserInfo,
+ setUserAvatar,
+ }
+ },
+ {
+ persist: true,
+ },
+)
diff --git a/app/app/src/style/iconfont.css b/app/app/src/style/iconfont.css
new file mode 100644
index 0000000..35da86c
--- /dev/null
+++ b/app/app/src/style/iconfont.css
@@ -0,0 +1,28 @@
+@font-face {
+ font-family: 'iconfont'; /* Project id 4543091 */
+ src:
+ url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAOwAAsAAAAAB9AAAANjAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACDHAqDBIJqATYCJAMQCwoABCAFhGcHPRvnBsgusG3kMyE15/44PsBX09waBHv0REDt97oHAQDFrOIyPirRiULQ+TJcXV0hCYTuVFcBC915/2vX/32Q80hkZ5PZGZ9snvwruVLloidKqYN6iKC53bOtbKwVLSIi3W6zCWZbs3VbER3j9JpGX3ySYcc94IQRTK5s4epS/jSqIgvg37qlY2/jwQN7D9ADpfRCmIknQByTscVZPTBr+hnnCKg2o4bjakvXEPjuY65DJGeJNtBUhn1JxOBuB2UZmUpBOXdsFp4oxOv4GHgs3h/+wRDcicqSZJG1q9kK1z/Af9NpqxjpC2QaAdpHlCFh4spcYXs5sMWpSk5wUj31G2dLQKVKkZ/w7f/8/i/A3JVUSZK9f7xIKJeU14IFpBI/Qfkkz46GT/CuaGREfCtKJUougWeQWHvVC5Lcz2BGS+SePR99vj3yjJx7h574tp7uWcOh4yfaTjS/245TT/vkQrN+a7RLkK8+Vd+bz+FSGh+9srDQKPeJ2s29z7ah4+efdoxefRbbGwfy7ht+SuIWukzsu1b6ePP+6kN1aamb47qsPim1Ia3xdEpDcl1dckPKGYnneI23+57r2W1Mmkqs6ajrChRCs5qyQ66rTVWhgZaG7toOeHm5cxn0sSQuNDEgcUTdNTSupKI1JRZih/JssAUKezPeOJJzbNozF6zWJuuVavVU5Tgtkop/SDzHa7ytvnCTq0PhkEfi4xLLtb0PuwyOAYqmrYQApFJyoJjTnfz+ve94vvv2f/yWgxl8Jd8Di2DRDPuob59mU/+VfDCROQyR8xSnmP9fXm7liagmN39OlmbvjqG0sMsJKrU0EFXogaRSH5bNY1CmxhyUq7QC1cY1T67RwuQk5CoM2RUQNLoEUb03kDS6h2XzcyjT7iOUa/QXqq1Hn6/GUBAaGcGcWJFlGUmCoVOp8kLvABHnVczGYiOE2SVEUH5OXj/TSnTCDjHAviAWcE4RZYaGWszNiKoayGSGTASeY+PcrMjNpVMvyREMDRoxBMYRVojFMkQiMOhohubdzxtAiOapMMbERpKMnQT9SL4ceQysVdJZVa9kEbsFogIcRyEUE2kN0mL7CDVIGhBzupWMEHA5bDvipgq5hKJcKef8ivbx1kC15KgcYkghhzLxYNntxoKCReJ82jAHAAA=')
+ format('woff2'),
+ url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.woff?t=1715485842402') format('woff'),
+ url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.ttf?t=1715485842402') format('truetype');
+}
+
+.iconfont {
+ font-family: 'iconfont' !important;
+ font-size: 16px;
+ font-style: normal;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-my:before {
+ content: '\e78c';
+}
+
+.icon-package:before {
+ content: '\e9c2';
+}
+
+.icon-chat:before {
+ content: '\e600';
+}
diff --git a/app/app/src/style/index.scss b/app/app/src/style/index.scss
new file mode 100644
index 0000000..df17558
--- /dev/null
+++ b/app/app/src/style/index.scss
@@ -0,0 +1,635 @@
+// 测试用的 iconfont,可生效
+// @import './iconfont.css';
+
+/* ===== Roboto Mono 数字字体(H5 端 CDN,小程序端回退 monospace) ===== */
+// #ifdef H5
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 500;
+ font-display: swap;
+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/roboto-mono@5.0.0/files/roboto-mono-latin-500-normal.woff2')
+ format('woff2');
+}
+
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/roboto-mono@5.0.0/files/roboto-mono-latin-600-normal.woff2')
+ format('woff2');
+}
+
+@font-face {
+ font-family: 'Roboto Mono';
+ font-style: normal;
+ font-weight: 700;
+ font-display: swap;
+ src: url('https://cdn.jsdelivr.net/npm/@fontsource/roboto-mono@5.0.0/files/roboto-mono-latin-700-normal.woff2')
+ format('woff2');
+}
+// #endif
+
+.test {
+ // 可以通过 @apply 多个样式封装整体样式
+ @apply mt-4 ml-4;
+
+ padding-top: 4px;
+ color: red;
+}
+
+:root,
+page {
+ /* 设计稿基线:浅灰画布、主色 #2563eb、深蓝 #0b2bac 仅用于渐变、状态色只承担语义提示 */
+ --wot-color-theme: #2563eb;
+ --wot-color-primary: #2563eb;
+ --wot-color-success: #16a34a;
+ --wot-color-warning: #f59e0b;
+ --app-primary: #2563eb;
+ --app-primary-strong: #0b2bac;
+ --app-canvas: #f6f8fb;
+ --app-surface: #ffffff;
+ --app-ink: #111827;
+ --app-body: #334155;
+ --app-muted: #64748b;
+ --app-line: #e2e8f0;
+ --app-primary-soft: #eff6ff;
+ font-family:
+ 'Noto Sans SC',
+ -apple-system,
+ BlinkMacSystemFont,
+ 'PingFang SC',
+ 'Helvetica Neue',
+ Helvetica,
+ 'Microsoft YaHei',
+ sans-serif;
+}
+
+page {
+ background: var(--app-canvas);
+ color: var(--app-ink);
+}
+
+/* 业务子页统一视觉:用于续费、变更、划拨、加油包、预警、诊断、消息等设计稿落地页 */
+.biz-page {
+ min-height: 100%;
+ background: var(--app-canvas);
+ padding: 20rpx 24rpx 160rpx;
+ box-sizing: border-box;
+}
+
+.biz-stack {
+ max-width: 920rpx;
+ margin: 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 16rpx;
+}
+
+.biz-hero {
+ box-sizing: border-box;
+ border: 1px solid #dbeafe;
+ border-radius: 16rpx;
+ background: #fff;
+ padding: 24rpx;
+ box-shadow: 0 6rpx 18rpx rgba(11, 43, 172, 0.06);
+}
+
+.biz-hero__top,
+.biz-section-head,
+.biz-row,
+.biz-paybar__summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16rpx;
+}
+
+.biz-hero__icon {
+ width: 60rpx;
+ height: 60rpx;
+ border-radius: 18rpx;
+ background: #2563eb;
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 32rpx;
+}
+
+.biz-hero__title,
+.biz-section-title {
+ display: block;
+ color: var(--app-ink);
+ font-weight: 900;
+ line-height: 1.25;
+}
+
+.biz-hero__title {
+ font-size: 34rpx;
+}
+
+.biz-hero__sub {
+ display: block;
+ margin-top: 6rpx;
+ color: var(--app-muted);
+ font-size: 23rpx;
+ line-height: 34rpx;
+}
+
+.biz-stat-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12rpx;
+ margin-top: 20rpx;
+}
+
+.biz-stat {
+ min-width: 0;
+ border-radius: 8px;
+ background: #f8fafc;
+ padding: 16rpx;
+}
+
+.biz-stat__label,
+.biz-paybar__label {
+ display: block;
+ color: var(--app-muted);
+ font-size: 22rpx;
+ line-height: 30rpx;
+}
+
+.biz-stat__value {
+ display: block;
+ margin-top: 6rpx;
+ color: var(--app-ink);
+ font-size: 25rpx;
+ font-weight: 800;
+ line-height: 34rpx;
+ word-break: break-word;
+}
+
+.biz-section-title {
+ font-size: 29rpx;
+}
+
+.biz-section-sub {
+ display: block;
+ margin-top: 4rpx;
+ color: var(--app-muted);
+ font-size: 22rpx;
+ line-height: 32rpx;
+}
+
+.biz-card {
+ border: 1px solid var(--app-line);
+ border-radius: 16rpx;
+ background: #fff;
+ overflow: hidden;
+ box-shadow: 0 6rpx 18rpx rgba(11, 43, 172, 0.06);
+}
+
+.biz-card__body {
+ padding: 22rpx;
+}
+
+.biz-row {
+ padding: 18rpx 0;
+ border-bottom: 1px solid #edf2f7;
+}
+
+.biz-row:last-child {
+ border-bottom: 0;
+}
+
+.biz-row__label {
+ flex-shrink: 0;
+ color: var(--app-muted);
+ font-size: 24rpx;
+}
+
+.biz-row__value {
+ min-width: 0;
+ flex: 1;
+ color: var(--app-ink);
+ font-size: 25rpx;
+ font-weight: 700;
+ text-align: right;
+ word-break: break-all;
+}
+
+.biz-option {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 16rpx;
+ min-height: 118rpx;
+ border: 1px solid var(--app-line);
+ border-radius: 8px;
+ background: #fff;
+ padding: 20rpx;
+ box-sizing: border-box;
+}
+
+.biz-option.is-active {
+ border-color: #2563eb;
+ background: #eff6ff;
+}
+
+/* ===== 业务两步选择区(步骤徽标 + 卡内选项列表) ===== */
+.biz-step {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36rpx;
+ height: 36rpx;
+ flex-shrink: 0;
+ border-radius: 10rpx;
+ background: linear-gradient(135deg, #2563eb, #0b2bac);
+ color: #fff;
+ font-size: 22rpx;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.biz-option-list {
+ display: grid;
+ gap: 14rpx;
+ padding: 20rpx;
+}
+
+.biz-option__main {
+ min-width: 0;
+ flex: 1;
+}
+
+.biz-option__title {
+ display: block;
+ color: var(--app-ink);
+ font-size: 28rpx;
+ font-weight: 900;
+ line-height: 36rpx;
+}
+
+.biz-option__meta {
+ display: block;
+ margin-top: 6rpx;
+ color: var(--app-muted);
+ font-size: 22rpx;
+ line-height: 32rpx;
+}
+
+.biz-option__price {
+ flex-shrink: 0;
+ color: #2563eb;
+ font-size: 30rpx;
+ font-weight: 900;
+ line-height: 40rpx;
+}
+
+.biz-radio {
+ width: 28rpx;
+ height: 28rpx;
+ border: 2rpx solid #cbd5e1;
+ border-radius: 50%;
+ box-sizing: border-box;
+ flex-shrink: 0;
+}
+
+.is-active > .biz-radio,
+.biz-radio.is-active {
+ border: 8rpx solid #2563eb;
+}
+
+.biz-check-row {
+ border: 1px solid var(--app-line);
+ border-radius: 8px;
+ background: #f8fafc;
+ padding: 16rpx;
+}
+
+.biz-check-row.is-active {
+ border-color: #2563eb;
+ background: #eff6ff;
+}
+
+.biz-mini-action-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10rpx;
+}
+
+.biz-mini-action-grid--2 {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.biz-mini-action-btn,
+.wd-button.biz-mini-action-btn {
+ width: 100% !important;
+ min-width: 0 !important;
+ height: 80rpx !important;
+ min-height: 80rpx !important;
+ padding: 0 10rpx !important;
+ border: 1px solid #dbe3ef !important;
+ border-color: #dbe3ef !important;
+ border-radius: 12rpx !important;
+ background: #fff !important;
+ box-shadow: none !important;
+ color: #1f2937 !important;
+ font-size: 23rpx !important;
+ font-weight: 700 !important;
+}
+
+.biz-mini-action-btn.is-disabled,
+.biz-mini-action-btn.wd-button--disabled,
+.wd-button.biz-mini-action-btn.is-disabled,
+.wd-button.biz-mini-action-btn.wd-button--disabled {
+ border-color: #e2e8f0 !important;
+ background: #f8fafc !important;
+ color: #94a3b8 !important;
+ opacity: 1 !important;
+}
+
+.biz-mini-action-btn .wd-button__text,
+.wd-button.biz-mini-action-btn .wd-button__text {
+ min-width: 0;
+ width: 100%;
+}
+
+.biz-mini-action-btn__inner {
+ display: inline-flex;
+ min-width: 0;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+ gap: 8rpx;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.biz-mini-action-btn__icon {
+ flex-shrink: 0;
+ color: #2563eb;
+ font-size: 28rpx;
+}
+
+.biz-mini-action-btn__text {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.biz-mini-action-btn__icon--blue {
+ color: #2563eb;
+}
+
+.biz-mini-action-btn__icon--green {
+ color: #2563eb;
+}
+
+.biz-mini-action-btn__icon--orange {
+ color: #2563eb;
+}
+
+.biz-mini-action-btn__icon--gray {
+ color: #2563eb;
+}
+
+.biz-paybar-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16rpx;
+}
+
+.biz-paybar-btn,
+.wd-button.biz-paybar-btn {
+ height: 78rpx !important;
+ min-height: 78rpx !important;
+ border-radius: 8px !important;
+ font-size: 26rpx !important;
+ font-weight: 800 !important;
+}
+
+.biz-paybar {
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 50;
+ border-top: 1px solid var(--app-line);
+ background: #fff;
+ padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom));
+ box-shadow: 0 -8rpx 24rpx rgba(15, 23, 42, 0.08);
+}
+
+.biz-paybar__inner {
+ max-width: 920rpx;
+ margin: 0 auto;
+}
+
+.biz-paybar__summary {
+ margin-bottom: 12rpx;
+}
+
+.biz-paybar__value {
+ min-width: 0;
+ color: var(--app-ink);
+ font-size: 25rpx;
+ font-weight: 800;
+ text-align: right;
+}
+
+.biz-note {
+ border: 1px solid #dbeafe;
+ border-radius: 8px;
+ background: #eff6ff;
+ color: #1d4ed8;
+ font-size: 23rpx;
+ line-height: 34rpx;
+ padding: 18rpx 20rpx;
+}
+
+.mono {
+ font-family: 'Roboto Mono', monospace;
+}
+
+@media (max-width: 360px) {
+ .biz-stat-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* 设计稿按钮规范(hy3):圆角矩形 24rpx,主操作为蓝紫渐变,线框为白底+主色描边。
+ 注意:仅「标签/徽章」使用胶囊形;按钮统一为圆角矩形,不再使用 999rpx 胶囊。 */
+.wd-button {
+ box-sizing: border-box;
+ min-height: 88rpx;
+ border-radius: 24rpx !important;
+ font-weight: 600 !important;
+ transition:
+ background-color 120ms ease,
+ border-color 120ms ease,
+ color 120ms ease;
+}
+
+.wd-button--small {
+ min-height: 72rpx;
+ padding-right: 28rpx !important;
+ padding-left: 28rpx !important;
+ border-radius: 16rpx !important;
+ font-size: 24rpx !important;
+}
+
+.wd-button--primary {
+ &:not(.is-plain):not(.is-disabled):not(.is-loading):not(.is-text) {
+ border: none !important;
+ background: linear-gradient(135deg, #2563eb 0%, #0b2bac 100%) !important;
+ color: #fff !important;
+ }
+
+ &.is-plain {
+ border-color: #2563eb !important;
+ background: #fff !important;
+ color: #2563eb !important;
+ }
+
+ &:not(.is-plain):not(.is-disabled):not(.is-loading):not(.is-text):active {
+ background: linear-gradient(135deg, #1d4ed8 0%, #0b2bac 100%) !important;
+ transform: translateY(1rpx);
+ }
+}
+
+/* 默认线框按钮对应设计稿 btn--ghost / mini-act:白底、灰边、正文色。 */
+.wd-button--default {
+ &.is-plain {
+ border-color: #e2e8f0 !important;
+ background: #fff !important;
+ color: #334155 !important;
+ }
+}
+
+.wd-button.is-disabled,
+.wd-button[disabled] {
+ border-color: #e2e8f0 !important;
+ background: #f1f5f9 !important;
+ color: #94a3b8 !important;
+ box-shadow: none !important;
+ opacity: 1 !important;
+}
+
+.wd-button--danger {
+ &:not(.is-plain):not(.is-disabled):not(.is-loading):not(.is-text) {
+ border: none !important;
+ background: #dc2626 !important;
+ color: #fff !important;
+ }
+
+ /* 危险 ghost(退出登录 / 取消订单):白底 + 危险描边 + 危险文字 */
+ &.is-plain {
+ border-color: #dc2626 !important;
+ background: #fff !important;
+ color: #dc2626 !important;
+ }
+}
+
+/* Wot UI 卡片统一采用设计稿:白底、1px 细描边、16rpx 圆角、轻蓝阴影 */
+.wd-card {
+ background: var(--app-surface) !important;
+ border: 1px solid var(--app-line) !important;
+ border-radius: 16rpx !important;
+ box-shadow: 0 6rpx 18rpx rgba(11, 43, 172, 0.06) !important;
+ overflow: hidden;
+}
+
+.wd-card__title {
+ padding: 24rpx !important;
+ font-weight: 600 !important;
+}
+
+/* ===== 设计稿通用区块(白卡 + 渐变竖条标题) ===== */
+.app-section {
+ box-sizing: border-box;
+ border: 1px solid var(--app-line);
+ border-radius: 16rpx;
+ background: #fff;
+ box-shadow: 0 6rpx 18rpx rgba(11, 43, 172, 0.06);
+ overflow: hidden;
+}
+
+.app-section__head {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ padding: 24rpx 28rpx;
+ border-bottom: 1px solid var(--app-line);
+}
+
+.app-title-bar {
+ width: 6rpx;
+ height: 30rpx;
+ flex-shrink: 0;
+ border-radius: 999rpx;
+ background: linear-gradient(180deg, #2563eb, #0b2bac);
+}
+
+.app-section__title {
+ color: var(--app-ink);
+ font-size: 30rpx;
+ font-weight: 800;
+ line-height: 40rpx;
+}
+
+/* ===== 设计稿标签 / 徽章(胶囊形,仅标签使用) ===== */
+.app-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 4rpx 16rpx;
+ border-radius: 999rpx;
+ font-size: 22rpx;
+ font-weight: 600;
+ line-height: 32rpx;
+}
+
+.app-tag--ok {
+ background: #dcfce7;
+ color: #16a34a;
+}
+
+.app-tag--warn {
+ background: #fff7ed;
+ color: #f59e0b;
+}
+
+.app-tag--low {
+ background: #e0f2fe;
+ color: #0ea5e9;
+}
+
+.app-tag--err {
+ background: #ffe4e6;
+ color: #f43f5e;
+}
+
+.app-tag--info {
+ background: #eff6ff;
+ color: #2563eb;
+}
+
+/*
+border-t-1
+由于uniapp中无法使用*选择器,使用魔法代替*,加上此规则可以简化border与divide的使用,并提升布局的兼容性
+1. 防止padding和border影响元素宽度。 (https://github.com/mozdevs/cssremedy/issues/4)
+2. 允许仅通过添加边框宽度来向元素添加边框。 (https://github.com/tailwindcss/tailwindcss/pull/116)
+3. [UnoCSS]: 允许使用css变量'--un-default-border-color'覆盖默认边框颜色
+*/
+// 这个样式有重大BUG,先去掉!!(2025-08-15)
+// :not(not),
+// ::before,
+// ::after {
+// box-sizing: border-box; /* 1 */
+// border-width: 0; /* 2 */
+// border-style: solid; /* 2 */
+// border-color: var(--un-default-border-color, #e5e7eb); /* 3 */
+// }
diff --git a/app/app/src/tabbar/README.md b/app/app/src/tabbar/README.md
new file mode 100644
index 0000000..575d87f
--- /dev/null
+++ b/app/app/src/tabbar/README.md
@@ -0,0 +1,78 @@
+# tabbar 说明
+
+## tabbar 3种策略
+
+`tabbar` 分为 `4 种` 情况:
+
+- 0 `无 tabbar`,只有一个页面入口,底部无 `tabbar` 显示;常用语临时活动页。
+
+- 1 `原生 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。
+ - 优势:原生自带的 `tabbar`,最先渲染,有缓存。
+ - 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。
+
+- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 `tabbar`,`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。
+ - 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。
+ - 劣势:首次点击 `tabbar` 会闪烁。
+
+## tabbar 配置说明
+
+- 如果使用的是 `原生tabbar`,需要配置 `nativeTabbarList`,每个 `item` 需要配置 `path`、`text`、`iconPath`、`selectedIconPath` 等属性。
+- 如果使用的是 `自定义tabbar`,需要配置 `customTabbarList`,每个 `item` 需要配置 `path`、`text`、`icon` 、`iconType` 等属性(如果是 `image` 图片还需要配置2种图片)。
+
+## 文件说明
+
+`config.ts` 专门配置 `nativeTabbarList` 和 `customTabbarList` 的相关信息,请按照文件里面的注释配置相关项。
+
+使用 `原生tabbar` 时,不需要关心下面2个文件:
+
+- `store.ts` ,专门给 `自定义 tabbar` 提供状态管理,代码几乎不需要修改。
+- `index.vue` ,专门给 `自定义 tabbar` 提供渲染逻辑,代码可以稍微修改,以符合自己的需求。
+
+## 自定义tabbar的不同类型的配置
+
+- uiLib 图标
+
+ ```js
+ {
+ // ... 其他配置
+ "iconType": "uniUi",
+ "icon": "home",
+ }
+ ```
+
+- unocss 图标
+
+ ```js
+ {
+ // ... 其他配置
+ // 注意 unocss 图标需要如下处理:(二选一)
+ // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
+ // 2)配置到 unocss.config.ts 的 safelist 中
+ iconType: 'unocss',
+ icon: 'i-carbon-code',
+ }
+ ```
+
+- iconfont 图标
+
+ ```js
+ {
+ // ... 其他配置
+ // 注意 iconfont 图标需要额外加上 'iconfont',如下
+ iconType: 'iconfont',
+ icon: 'iconfont icon-my',
+ }
+ ```
+
+- image 本地图片
+
+ ```js
+ {
+ // ... 其他配置
+ // 使用 ‘image’时,需要配置 icon + iconActive 2张图片(不推荐)
+ // 既然已经用了自定义tabbar了,就不建议用图片了,所以不推荐
+ iconType: 'image',
+ icon: '/static/tabbar/home.png',
+ iconActive: '/static/tabbar/homeHL.png',
+ }
+ ```
diff --git a/app/app/src/tabbar/TabbarItem.vue b/app/app/src/tabbar/TabbarItem.vue
new file mode 100644
index 0000000..5478428
--- /dev/null
+++ b/app/app/src/tabbar/TabbarItem.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.text }}
+
+
+
+
+
+
+
+
+ {{ item.badge > 99 ? '99+' : item.badge }}
+
+
+
+
+
+
+
diff --git a/app/app/src/tabbar/config.ts b/app/app/src/tabbar/config.ts
new file mode 100644
index 0000000..b45e417
--- /dev/null
+++ b/app/app/src/tabbar/config.ts
@@ -0,0 +1,152 @@
+import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
+import type { CustomTabBarItem, NativeTabBarItem } from './types'
+import { V101_RELEASE } from '../constant/release'
+
+/**
+ * tabbar 选择的策略,更详细的介绍见 tabbar.md 文件
+ * 0: 'NO_TABBAR' `无 tabbar`
+ * 1: 'NATIVE_TABBAR' `原生 tabbar`
+ * 2: 'CUSTOM_TABBAR' `自定义 tabbar`
+ *
+ * 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致配置不生效
+ */
+export const TABBAR_STRATEGY_MAP = {
+ NO_TABBAR: 0,
+ NATIVE_TABBAR: 1,
+ CUSTOM_TABBAR: 2,
+}
+
+// TODO: 1/3. 通过这里切换使用tabbar的策略
+// 如果是使用 NO_TABBAR(0),nativeTabbarList 和 customTabbarList 都不生效
+// 如果是使用 NATIVE_TABBAR(1),只需要配置 nativeTabbarList,customTabbarList 不生效
+// 如果是使用 CUSTOM_TABBAR(2),只需要配置 customTabbarList,nativeTabbarList 不生效
+export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.CUSTOM_TABBAR
+
+// TODO: 2/3. 使用 NATIVE_TABBAR 时,更新下面的 tabbar 配置
+export const nativeTabbarList: NativeTabBarItem[] = [
+ {
+ iconPath: 'static/tabbar/home.png',
+ selectedIconPath: 'static/tabbar/homeHL.png',
+ pagePath: 'pages/home/index',
+ text: '%tabbar.home%',
+ },
+ {
+ iconPath: 'static/tabbar/personal.png',
+ selectedIconPath: 'static/tabbar/personalHL.png',
+ pagePath: 'pages/user/index',
+ text: '%tabbar.me%',
+ },
+]
+
+// TODO: 3/3. 使用 CUSTOM_TABBAR 时,更新下面的 tabbar 配置
+// 如果需要配置鼓包,需要在 'tabbar/store.ts' 里面设置,最后在 `tabbar/index.vue` 里面更改鼓包的图片
+const v101CustomTabbarList: CustomTabBarItem[] = [
+ {
+ text: '首页',
+ pagePath: 'pages/home/index',
+ // 注意 unocss 图标需要如下处理:(二选一)
+ // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
+ // 2)配置到 unocss.config.ts 的 safelist 中
+ iconType: 'uiLib',
+ icon: 'home',
+ // badge: 'dot',
+ },
+ {
+ pagePath: 'pages/card/index',
+ text: '设备',
+ iconType: 'uiLib',
+ icon: 'nav',
+ },
+ {
+ pagePath: 'pages/user/index',
+ text: '我的',
+ iconType: 'uiLib',
+ icon: 'user',
+ },
+
+ // 其他类型演示
+ // 1、uiLib
+ // {
+ // pagePath: 'pages/index/index',
+ // text: '首页',
+ // iconType: 'uiLib',
+ // icon: 'home',
+ // },
+ // 2、iconfont
+ // {
+ // pagePath: 'pages/index/index',
+ // text: '首页',
+ // // 注意 iconfont 图标需要额外加上 'iconfont',如下
+ // iconType: 'iconfont',
+ // icon: 'iconfont icon-my',
+ // },
+ // 3、image
+ // {
+ // pagePath: 'pages/index/index',
+ // text: '首页',
+ // // 使用 ‘image’时,需要配置 icon + iconActive 2张图片
+ // iconType: 'image',
+ // icon: '/static/tabbar/home.png',
+ // iconActive: '/static/tabbar/homeHL.png',
+ // },
+]
+
+export const customTabbarList: CustomTabBarItem[] = V101_RELEASE.showBusinessTab
+ ? [
+ ...v101CustomTabbarList.slice(0, 2),
+ {
+ pagePath: 'pages/business/index',
+ text: '办理',
+ iconType: 'uiLib',
+ icon: 'failpayment',
+ },
+ ...v101CustomTabbarList.slice(2),
+ ]
+ : v101CustomTabbarList
+
+/**
+ * 是否启用 tabbar 缓存
+ * NATIVE_TABBAR(1) 和 CUSTOM_TABBAR(2) 时,需要tabbar缓存
+ */
+export const tabbarCacheEnable = [
+ TABBAR_STRATEGY_MAP.NATIVE_TABBAR,
+ TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
+].includes(selectedTabbarStrategy)
+
+/**
+ * 是否启用自定义 tabbar
+ * CUSTOM_TABBAR(2) 时,启用自定义tabbar
+ */
+export const customTabbarEnable = [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(
+ selectedTabbarStrategy,
+)
+
+/**
+ * 是否需要隐藏原生 tabbar
+ * CUSTOM_TABBAR(2) 时,需要隐藏原生tabbar
+ */
+export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR
+
+const _tabbarList = customTabbarEnable
+ ? customTabbarList.map(item => ({ text: item.text, pagePath: item.pagePath }))
+ : nativeTabbarList
+export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList
+
+// NATIVE_TABBAR(1) 时,显示原生Tabbar,在i18n的情况下需要 setTabbarItem (框架已经处理)
+export const isNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NATIVE_TABBAR
+
+const _tabbar: TabBar = {
+ // 只有微信小程序支持 custom。App 和 H5 不生效
+ custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
+ color: '#94a3b8',
+ selectedColor: '#2563eb',
+ backgroundColor: '#F8F8F8',
+ borderStyle: 'black',
+ height: '50px',
+ fontSize: '10px',
+ iconWidth: '24px',
+ spacing: '3px',
+ list: _tabbarList as unknown as TabBar['list'],
+}
+
+export const tabBar = tabbarCacheEnable ? _tabbar : undefined
diff --git a/app/app/src/tabbar/i18n.ts b/app/app/src/tabbar/i18n.ts
new file mode 100644
index 0000000..e50f689
--- /dev/null
+++ b/app/app/src/tabbar/i18n.ts
@@ -0,0 +1,30 @@
+import { t } from '@/locale'
+import { isCurrentPageTabbar } from '@/utils'
+import { isNativeTabbar, tabbarList } from './config'
+
+// h5 中一直可以生效,小程序里面默认是无法动态切换的,这里借助vue模板自带响应式的方式
+// 直接替换 %xxx% 为 t('xxx')即可
+export function getI18nText(key: string) {
+ // 获取 %xxx% 中的 xxx
+ const match = key.match(/%(.+?)%/)
+ if (match) {
+ key = match[1]
+ }
+ console.log('设置多语言:', key)
+ return t(key)
+}
+
+export function setTabbarItem() {
+// 只有使用原生Tabbar才需要 setTabbarItem
+// 而且只有当前页是tabbar页才能设置
+ // console.log('设置多语言:setTabBarItem', isNativeTabbar, isCurrentPageTabbar())
+ if (isNativeTabbar && isCurrentPageTabbar()) {
+ tabbarList.forEach((item, index) => {
+ uni.setTabBarItem({
+ index,
+ // text: getI18nText(item.text),
+ text: item.text,
+ })
+ })
+ }
+}
diff --git a/app/app/src/tabbar/index.vue b/app/app/src/tabbar/index.vue
new file mode 100644
index 0000000..26f9957
--- /dev/null
+++ b/app/app/src/tabbar/index.vue
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/app/src/tabbar/store.ts b/app/app/src/tabbar/store.ts
new file mode 100644
index 0000000..49cfc8e
--- /dev/null
+++ b/app/app/src/tabbar/store.ts
@@ -0,0 +1,105 @@
+import type { CustomTabBarItem, CustomTabBarItemBadge } from './types'
+import { computed, reactive } from 'vue'
+
+import { tabbarList as _tabbarList, selectedTabbarStrategy, TABBAR_STRATEGY_MAP } from './config'
+
+/** tabbarList 里面的 path 从 pages.config.ts 得到 */
+const baseTabbarList = reactive(_tabbarList.map(item => ({
+ ...item,
+ pagePath: item.pagePath.startsWith('/') ? item.pagePath : `/${item.pagePath}`, // 统一成 '/' 开头的路径
+})))
+
+const tabbarList = computed(() => baseTabbarList)
+
+export function isPageTabbar(path: string) {
+ if (selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NO_TABBAR) {
+ return false
+ }
+ const _path = normalizeRoutePath(path)
+ return _path === '/' || tabbarList.value.some(item => item.pagePath === _path)
+}
+
+export function normalizeRoutePath(path?: string) {
+ if (!path) {
+ return ''
+ }
+ const _path = path.split('?')[0]
+ return _path.startsWith('/') ? _path : `/${_path}`
+}
+
+function getCurrentPagePath() {
+ const pages = getCurrentPages()
+ const currentPage = pages[pages.length - 1]
+ return normalizeRoutePath(currentPage?.route)
+}
+
+function findTabbarIndexByPath(path?: string) {
+ const normalizedPath = normalizeRoutePath(path)
+ if (normalizedPath === '/') {
+ return 0
+ }
+ return tabbarList.value.findIndex(item => item.pagePath === normalizedPath)
+}
+
+/**
+ * 自定义 tabbar 的状态管理,原生 tabbar 无需关注本文件
+ * tabbar 状态,增加 storageSync 保证刷新浏览器时在正确的 tabbar 页面
+ * 使用reactive简单状态,而不是 pinia 全局状态
+ */
+const tabbarStore = reactive({
+ curIdx: uni.getStorageSync('app-tabbar-index') || 0,
+ prevIdx: uni.getStorageSync('app-tabbar-index') || 0,
+ setCurIdx(idx: number) {
+ this.curIdx = idx
+ uni.setStorageSync('app-tabbar-index', idx)
+ },
+ setTabbarItemBadge(idx: number, badge: CustomTabBarItemBadge) {
+ const list = tabbarList.value
+ if (list[idx]) {
+ list[idx].badge = badge
+ }
+ },
+ setAutoCurIdx(path: string) {
+ const list = tabbarList.value
+ if (list.length === 0) {
+ this.setCurIdx(0)
+ return
+ }
+
+ const index = findTabbarIndexByPath(path)
+ if (index >= 0) {
+ this.setCurIdx(index)
+ return
+ }
+
+ if (this.curIdx < 0 || this.curIdx >= list.length) {
+ this.setCurIdx(0)
+ }
+ },
+ syncCurIdxByCurrentPage() {
+ const currentPath = getCurrentPagePath()
+ if (currentPath) {
+ this.setAutoCurIdx(currentPath)
+ }
+ },
+ syncCurIdxByCurrentPageAsync() {
+ setTimeout(() => {
+ this.syncCurIdxByCurrentPage()
+ }, 0)
+ },
+ isCurrentRouteTabbarItem(index: number) {
+ const item = tabbarList.value[index]
+ if (!item) {
+ return false
+ }
+ return findTabbarIndexByPath(getCurrentPagePath()) === index
+ },
+ restorePrevIdx() {
+ if (this.prevIdx === this.curIdx)
+ return
+ this.setCurIdx(this.prevIdx)
+ this.prevIdx = uni.getStorageSync('app-tabbar-index') || 0
+ },
+})
+
+export { tabbarList, tabbarStore }
diff --git a/app/app/src/tabbar/types.ts b/app/app/src/tabbar/types.ts
new file mode 100644
index 0000000..7db6060
--- /dev/null
+++ b/app/app/src/tabbar/types.ts
@@ -0,0 +1,39 @@
+import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
+import type { UserRole } from '@/api/types/login'
+import type { RemoveLeadingSlashFromUnion } from '@/typings'
+
+/**
+ * 原生 tabbar 的单个选项配置
+ */
+export type NativeTabBarItem = TabBar['list'][number] & {
+ pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
+}
+
+/** badge 显示一个数字或 小红点(样式可以直接在 tabbar/index.vue 里面修改) */
+export type CustomTabBarItemBadge = number | 'dot'
+
+/** 自定义 tabbar 的单个选项配置 */
+export interface CustomTabBarItem {
+ text: string
+ pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
+ /** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */
+ iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image'
+ /**
+ * icon 的路径
+ * - uiLib: wot-design-uni 图标的 icon prop
+ * - unocss: unocss 图标的类名
+ * - iconfont: iconfont 图标的类名
+ * - image: 图片的路径
+ */
+ icon: string
+ /** 只有在 image 模式下才需要,传递的是高亮的图片 */
+ iconActive?: string
+ /** badge 显示一个数字或 小红点 */
+ badge?: CustomTabBarItemBadge
+ /** 是否是中间的鼓包tabbarItem */
+ isBulge?: boolean
+ // roles 不写 → 所有用户都能看到;roles 写了 → 只有匹配角色可见
+ roles?: UserRole[]
+ // features 不写 → 所有用户都能看到;写了 → 有 app 权限配置时按业务能力过滤
+ features?: string[]
+}
diff --git a/app/app/src/types/async-component.d.ts b/app/app/src/types/async-component.d.ts
new file mode 100644
index 0000000..f200205
--- /dev/null
+++ b/app/app/src/types/async-component.d.ts
@@ -0,0 +1,8 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by @uni-ku/bundle-optimizer
+declare module '*?async' {
+ const component: any
+ export = component
+}
diff --git a/app/app/src/types/async-import.d.ts b/app/app/src/types/async-import.d.ts
new file mode 100644
index 0000000..94101bc
--- /dev/null
+++ b/app/app/src/types/async-import.d.ts
@@ -0,0 +1,13 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// Generated by @uni-ku/bundle-optimizer
+export {}
+
+interface ModuleMap {
+ [path: string]: any
+}
+
+declare global {
+ function AsyncImport(arg: T): Promise
+}
diff --git a/app/app/src/types/auto-import.d.ts b/app/app/src/types/auto-import.d.ts
new file mode 100644
index 0000000..c0ab6fc
--- /dev/null
+++ b/app/app/src/types/auto-import.d.ts
@@ -0,0 +1,193 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+// biome-ignore lint: disable
+export {}
+declare global {
+ const EffectScope: typeof import('vue')['EffectScope']
+ const computed: typeof import('vue')['computed']
+ const createApp: typeof import('vue')['createApp']
+ const customRef: typeof import('vue')['customRef']
+ const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
+ const defineComponent: typeof import('vue')['defineComponent']
+ const effectScope: typeof import('vue')['effectScope']
+ const getCurrentInstance: typeof import('vue')['getCurrentInstance']
+ const getCurrentScope: typeof import('vue')['getCurrentScope']
+ const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
+ const h: typeof import('vue')['h']
+ const inject: typeof import('vue')['inject']
+ const isProxy: typeof import('vue')['isProxy']
+ const isReactive: typeof import('vue')['isReactive']
+ const isReadonly: typeof import('vue')['isReadonly']
+ const isRef: typeof import('vue')['isRef']
+ const isShallow: typeof import('vue')['isShallow']
+ const markRaw: typeof import('vue')['markRaw']
+ const nextTick: typeof import('vue')['nextTick']
+ const onActivated: typeof import('vue')['onActivated']
+ const onAddToFavorites: typeof import('@dcloudio/uni-app')['onAddToFavorites']
+ const onBackPress: typeof import('@dcloudio/uni-app')['onBackPress']
+ const onBeforeMount: typeof import('vue')['onBeforeMount']
+ const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
+ const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
+ const onDeactivated: typeof import('vue')['onDeactivated']
+ const onError: typeof import('@dcloudio/uni-app')['onError']
+ const onErrorCaptured: typeof import('vue')['onErrorCaptured']
+ const onHide: typeof import('@dcloudio/uni-app')['onHide']
+ const onLaunch: typeof import('@dcloudio/uni-app')['onLaunch']
+ const onLoad: typeof import('@dcloudio/uni-app')['onLoad']
+ const onMounted: typeof import('vue')['onMounted']
+ const onNavigationBarButtonTap: typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']
+ const onNavigationBarSearchInputChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']
+ const onNavigationBarSearchInputClicked: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']
+ const onNavigationBarSearchInputConfirmed: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']
+ const onNavigationBarSearchInputFocusChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']
+ const onPageNotFound: typeof import('@dcloudio/uni-app')['onPageNotFound']
+ const onPageScroll: typeof import('@dcloudio/uni-app')['onPageScroll']
+ const onPullDownRefresh: typeof import('@dcloudio/uni-app')['onPullDownRefresh']
+ const onReachBottom: typeof import('@dcloudio/uni-app')['onReachBottom']
+ const onReady: typeof import('@dcloudio/uni-app')['onReady']
+ const onRenderTracked: typeof import('vue')['onRenderTracked']
+ const onRenderTriggered: typeof import('vue')['onRenderTriggered']
+ const onResize: typeof import('@dcloudio/uni-app')['onResize']
+ const onScopeDispose: typeof import('vue')['onScopeDispose']
+ const onServerPrefetch: typeof import('vue')['onServerPrefetch']
+ const onShareAppMessage: typeof import('@dcloudio/uni-app')['onShareAppMessage']
+ const onShareTimeline: typeof import('@dcloudio/uni-app')['onShareTimeline']
+ const onShow: typeof import('@dcloudio/uni-app')['onShow']
+ const onTabItemTap: typeof import('@dcloudio/uni-app')['onTabItemTap']
+ const onThemeChange: typeof import('@dcloudio/uni-app')['onThemeChange']
+ const onUnhandledRejection: typeof import('@dcloudio/uni-app')['onUnhandledRejection']
+ const onUnload: typeof import('@dcloudio/uni-app')['onUnload']
+ const onUnmounted: typeof import('vue')['onUnmounted']
+ const onUpdated: typeof import('vue')['onUpdated']
+ const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
+ const provide: typeof import('vue')['provide']
+ const reactive: typeof import('vue')['reactive']
+ const readonly: typeof import('vue')['readonly']
+ const ref: typeof import('vue')['ref']
+ const resolveComponent: typeof import('vue')['resolveComponent']
+ const shallowReactive: typeof import('vue')['shallowReactive']
+ const shallowReadonly: typeof import('vue')['shallowReadonly']
+ const shallowRef: typeof import('vue')['shallowRef']
+ const toRaw: typeof import('vue')['toRaw']
+ const toRef: typeof import('vue')['toRef']
+ const toRefs: typeof import('vue')['toRefs']
+ const toValue: typeof import('vue')['toValue']
+ const triggerRef: typeof import('vue')['triggerRef']
+ const unref: typeof import('vue')['unref']
+ const useAttrs: typeof import('vue')['useAttrs']
+ const useCssModule: typeof import('vue')['useCssModule']
+ const useCssVars: typeof import('vue')['useCssVars']
+ const useId: typeof import('vue')['useId']
+ const useModel: typeof import('vue')['useModel']
+ const useRequest: typeof import('../hooks/useRequest')['default']
+ const useScroll: typeof import('../hooks/useScroll')['useScroll']
+ const useSlots: typeof import('vue')['useSlots']
+ const useTemplateRef: typeof import('vue')['useTemplateRef']
+ const useUpload: typeof import('../hooks/useUpload')['default']
+ const watch: typeof import('vue')['watch']
+ const watchEffect: typeof import('vue')['watchEffect']
+ const watchPostEffect: typeof import('vue')['watchPostEffect']
+ const watchSyncEffect: typeof import('vue')['watchSyncEffect']
+}
+// for type re-export
+declare global {
+ // @ts-ignore
+ export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
+ import('vue')
+}
+
+// for vue template auto import
+import { UnwrapRef } from 'vue'
+declare module 'vue' {
+ interface GlobalComponents {}
+ interface ComponentCustomProperties {
+ readonly EffectScope: UnwrapRef
+ readonly computed: UnwrapRef
+ readonly createApp: UnwrapRef
+ readonly customRef: UnwrapRef
+ readonly defineAsyncComponent: UnwrapRef
+ readonly defineComponent: UnwrapRef
+ readonly effectScope: UnwrapRef
+ readonly getCurrentInstance: UnwrapRef
+ readonly getCurrentScope: UnwrapRef
+ readonly getCurrentWatcher: UnwrapRef
+ readonly h: UnwrapRef
+ readonly inject: UnwrapRef
+ readonly isProxy: UnwrapRef
+ readonly isReactive: UnwrapRef
+ readonly isReadonly: UnwrapRef
+ readonly isRef: UnwrapRef
+ readonly isShallow: UnwrapRef
+ readonly markRaw: UnwrapRef
+ readonly nextTick: UnwrapRef
+ readonly onActivated: UnwrapRef
+ readonly onAddToFavorites: UnwrapRef
+ readonly onBackPress: UnwrapRef