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)二次开发。 + +[![node version](https://img.shields.io/badge/node-%3E%3D18-green)](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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) + throw new Error('未找到附件上传脚本') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2020 } }).outputText, context) + await runInContext('upload("wxfile://random.pdf", "说明.pdf", 20)', context) + expect(upload).toHaveBeenCalledWith(expect.objectContaining({ url: '/work-order/upload', formData: { fileType: 'file', originalName: '说明.pdf' } })) + expect(emit).toHaveBeenCalledWith('update:modelValue', ['https://files/random.pdf']) + expect(runInContext('nameOf("https://files/random.pdf")', context)).toBe('说明.pdf') + expect(source).toContain('form.append(\'originalName\', name)') + }) +}) diff --git a/app/app/src/components/WorkOrderAttachments.vue b/app/app/src/components/WorkOrderAttachments.vue new file mode 100644 index 0000000..5ea52bf --- /dev/null +++ b/app/app/src/components/WorkOrderAttachments.vue @@ -0,0 +1,162 @@ + + + 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 + + + + + +``` + +## 实现步骤总结 + +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 @@ + + + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) + throw new Error('Business page script not found') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context) + return { + titles: () => Array.from(runInContext('sections.value.flatMap(section => section.items.map(item => item.title))', context)) as string[], + action: (title: string) => runInContext(`handleBusinessAction(sections.value.flatMap(section => section.items).find(item => item.title === ${JSON.stringify(title)}))`, context) as Promise, + fetchUserInfo, + navigateTo, + switchTab, + } +} + +function visibleTitles(userInfo: Partial) { + return createHarness(userInfo).titles() +} + +describe('办理入口发布范围', () => { + it('沿用紧凑办理布局,不展示大块消息提醒入口', () => { + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + const template = source.split('')[0] + expect(template).toContain('assist-card') + expect(template).toContain(':busy="actionPending"') + expect(template).not.toContain('查看业务消息') + expect(template).not.toContain('微信提醒设置') + expect(template).not.toContain('业务办理') + }) + + it('身份标签放在业务标题旁,保持单行且不挤占右侧区域', () => { + const source = readFileSync(new URL('./components/BusinessSection.vue', import.meta.url), 'utf8') + expect(source).toContain('biz-title-row') + expect(source).toContain('app-tag app-tag--info biz-role-tag') + expect(source).toContain('white-space: nowrap') + expect(source).not.toContain('title-width="75%"') + }) + + it('同一办理入口连续点击只跳转一次', async () => { + const harness = createHarness({ userId: 1, username: 'admin' }) + await Promise.all([harness.action('我的卡片'), harness.action('我的卡片')]) + expect(harness.fetchUserInfo).toHaveBeenCalledTimes(1) + expect(harness.switchTab).toHaveBeenCalledTimes(1) + expect(harness.switchTab.mock.calls[0][0]).toMatchObject({ url: RouterPath.Card }) + }) + + it('权限更新后不再跳转已失效的客户入口', async () => { + const user = { userId: 12, userType: 'sys_user', appAccess: { configured: true, features: ['customer:list'] } } + const harness = createHarness(user) + harness.fetchUserInfo.mockImplementationOnce(async () => { + user.appAccess.features = [] + return user + }) + await harness.action('客户管理') + expect(harness.navigateTo).not.toHaveBeenCalled() + }) + + it('工单入口按列表或提交权限开放,路由可达', () => { + for (const feature of ['workorder:list', 'workorder:create']) + expect(visibleTitles({ userId: 12, appAccess: { configured: true, features: [feature] } })).toContain('工单服务') + expect(visibleTitles({ userId: 12, appAccess: { configured: true, features: [] } })).not.toContain('工单服务') + for (const route of [RouterPath.BusinessViewsWorkOrder, RouterPath.BusinessViewsWorkOrderCreate, RouterPath.BusinessViewsWorkOrderDetail]) + expect(release.isV101HiddenRoute(route)).toBe(false) + expect(release.isV101HiddenRoute(RouterPath.BusinessViewsRenew)).toBe(true) + }) + it('批次日志入口只对拥有原有日志权限的账号开放', () => { + expect(visibleTitles({ userId: 12, userType: 'sys_user', accessCodes: ['cpm:common:batchExecutionLog:list'] })).toContain('批次执行日志') + expect(visibleTitles({ userId: 13, userType: 'sys_user', accessCodes: [] })).not.toContain('批次执行日志') + }) + it('恢复首页、设备、办理、我的顺序', () => { + expect(customTabbarList.map(item => item.text)).toEqual(['首页', '设备', '办理', '我的']) + expect(release.isV101HiddenRoute('/pages/business/index')).toBe(false) + }) + + it('管理员只能看到已开放业务,不展示仍受发布限制的操作', () => { + expect(visibleTitles({ userId: 1, username: 'admin' })).toEqual(['卡片排查', '卡片划拨', '工单服务', '我的卡片', '客户管理']) + }) + + it('客户入口继续受账号身份和权限限制', () => { + expect(visibleTitles({ userId: 12, userType: 'sys_user', appAccess: { configured: true, features: ['customer:list'] } })).toContain('客户管理') + expect(visibleTitles({ userId: 13, userType: 'sys_user', appAccess: { configured: true, features: [] } })).not.toContain('客户管理') + expect(visibleTitles({ userId: 14, userType: 'app_user' })).not.toContain('客户管理') + }) +}) diff --git a/app/app/src/pages/business/index.vue b/app/app/src/pages/business/index.vue new file mode 100644 index 0000000..259cdf5 --- /dev/null +++ b/app/app/src/pages/business/index.vue @@ -0,0 +1,226 @@ + + + + + + + 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 @@ + + + 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 @@ + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) + throw new Error('missing script') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context) + runInContext('chosenTarget.value = { deptId: 20, deptName: \'目标客户\' }; selectedCardValues.value = [\'CARD\']', context) + return { submit, summary, toast, removeStorage, evaluate: (code: string) => runInContext(code, context) } +} + +describe('异步划拨提交', () => { + it('受理后清空选择和任务状态,不自动查询或轮询结果', async () => { + const page = harness() + page.submit.mockResolvedValue({ batchNo: 'ALC202610130001', status: 'pending' }) + await page.evaluate('handleSubmit()') + expect(page.submit).toHaveBeenCalledWith({ cardValues: ['CARD'], targetDeptId: 20, executionMode: 2 }) + expect(page.summary).not.toHaveBeenCalled() + expect(page.evaluate('result.value')).toBeUndefined() + expect(page.evaluate('selectedCardValues.value.length')).toBe(0) + expect(page.removeStorage).toHaveBeenCalledWith('allocate-last-batch:A:12') + expect(page.toast).toHaveBeenCalledWith({ title: '已提交,请到批次日志查看', icon: 'none', duration: 3000 }) + }) + it('请求结果未知时保留防重复提交保护,不清空选择', async () => { + const page = harness() + page.submit.mockRejectedValue(new Error('timeout')) + await page.evaluate('handleSubmit()') + expect(page.evaluate('result.value.status')).toBe('unknown') + expect(page.evaluate('canSubmit.value')).toBe(false) + expect(page.evaluate('selectedCardValues.value.length')).toBe(1) + expect(page.removeStorage).not.toHaveBeenCalled() + expect(page.summary).not.toHaveBeenCalled() + }) +}) diff --git a/app/app/src/pages/business/views/api/balance.ts b/app/app/src/pages/business/views/api/balance.ts new file mode 100644 index 0000000..e848c9c --- /dev/null +++ b/app/app/src/pages/business/views/api/balance.ts @@ -0,0 +1,61 @@ +import type { + BalanceRechargeMockPayParams, + BalanceRechargeRefundParams, + BalanceRechargeStatusParams, + BalanceRechargeStatusRes, + BalanceRechargeSubmitParams, + BalanceRechargeSubmitRes, +} from '@/api/types/balance' +import type { AppOrder } from '@/api/types/order' +import { http } from '@/http/http' + +export type { + BalanceRechargeMockPayParams, + BalanceRechargeRefundParams, + BalanceRechargeStatusParams, + BalanceRechargeStatusRes, + BalanceRechargeSubmitParams, + BalanceRechargeSubmitRes, +} from '@/api/types/balance' + +export function balanceRechargeSubmit(data: BalanceRechargeSubmitParams) { + return http.post( + '/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/pages/business/views/api/customer.ts b/app/app/src/pages/business/views/api/customer.ts new file mode 100644 index 0000000..5bd66ab --- /dev/null +++ b/app/app/src/pages/business/views/api/customer.ts @@ -0,0 +1,27 @@ +import type { + CustomerDetailParams, + CustomerDetailRes, + CustomerListParams, + CustomerListRes, +} from '@/api/types/customer' +import { http } from '@/http/http' + +export type { + AppCustomer, + AppCustomerDetail, + AppCustomerType, + CustomerDetailParams, + CustomerListParams, +} from '@/api/types/customer' + +export function getCustomerList(params: CustomerListParams) { + return http.get('/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/pages/business/views/api/order.ts b/app/app/src/pages/business/views/api/order.ts new file mode 100644 index 0000000..f19f09f --- /dev/null +++ b/app/app/src/pages/business/views/api/order.ts @@ -0,0 +1,21 @@ +import type { + OrderDetailParams, + OrderDetailRes, + OrderListParams, + OrderListRes, +} from '@/api/types/order' +import { http } from '@/http/http' + +export type { AppOrder, AppOrderItem, OrderDetailParams, OrderListParams } from '@/api/types/order' + +export function getOrderList(params: OrderListParams) { + return http.get('/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/pages/business/views/api/warning.ts b/app/app/src/pages/business/views/api/warning.ts new file mode 100644 index 0000000..983d7f2 --- /dev/null +++ b/app/app/src/pages/business/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/business/views/api/workOrder.ts b/app/app/src/pages/business/views/api/workOrder.ts new file mode 100644 index 0000000..2510913 --- /dev/null +++ b/app/app/src/pages/business/views/api/workOrder.ts @@ -0,0 +1 @@ +export * from '@/api/workOrder' diff --git a/app/app/src/pages/business/views/balanceRecharge/index.vue b/app/app/src/pages/business/views/balanceRecharge/index.vue new file mode 100644 index 0000000..ce715b1 --- /dev/null +++ b/app/app/src/pages/business/views/balanceRecharge/index.vue @@ -0,0 +1,171 @@ + + + + + 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 @@ + + + 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 @@ + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) + throw new Error('Customer script not found') + const compiled = transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }) + runInContext(compiled.outputText, context) + return { + requests, + features, + evaluate: (expression: string) => runInContext(expression, context) as Result, + unmount: () => unmountCallbacks.forEach(callback => callback()), + } +} + +function result(customerId: number, total = 2): CustomerListRes { + return { + items: [{ customerId, customerName: `客户${customerId}`, customerType: 'enterprise', customerTypeName: '企业客户', status: '0', statusText: '正常', realNameStatus: '已录入实名资料', cardCount: 0, normalCardCount: 0 }], + total, + summary: { total, normalCount: total, pendingProfileCount: 0 }, + } +} + +describe('客户列表请求状态', () => { + it('列表不展示机构编号或内部ID替代编号', () => { + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + expect(source).not.toContain('customerLabel') + expect(source).not.toContain('customerCode') + expect(source).not.toContain('ID ${') + expect(source).not.toContain('搜索企业名称 / 编码') + }) + it('加载更多沿用已提交的搜索条件', async () => { + const harness = createHarness() + harness.requests.mockResolvedValueOnce(result(1)).mockResolvedValueOnce(result(2)) + harness.evaluate('keyword.value = \'企业甲\'') + await harness.evaluate>('loadCustomers(true)') + harness.evaluate('keyword.value = \'企业乙\'') + await harness.evaluate>('loadCustomers(false)') + expect(harness.requests.mock.calls[1][0]).toMatchObject({ page: 2, keyword: '企业甲' }) + expect(harness.requests.mock.calls[1][0]).not.toHaveProperty('status') + expect(harness.evaluate('customers.value.map(item => item.customerId)')).toEqual([1, 2]) + }) + + it('旧搜索晚返回时不覆盖新结果或总数', async () => { + const harness = createHarness() + let resolveOld!: (value: CustomerListRes) => void + harness.requests.mockImplementationOnce(() => new Promise((resolve) => { + resolveOld = resolve + })) + harness.requests.mockResolvedValueOnce(result(2, 3)) + const oldRequest = harness.evaluate>('loadCustomers(true)') + await harness.evaluate>('loadCustomers(true)') + resolveOld(result(1, 10)) + await oldRequest + expect(harness.evaluate('customers.value.map(item => item.customerId)')).toEqual([2]) + expect(harness.evaluate('total.value')).toBe(3) + expect(harness.evaluate('loading.value')).toBe(false) + }) + + it('页面卸载后不再处理返回结果或发起请求', async () => { + const harness = createHarness() + let resolveRequest!: (value: CustomerListRes) => void + harness.requests.mockImplementationOnce(() => new Promise((resolve) => { + resolveRequest = resolve + })) + const request = harness.evaluate>('loadCustomers(true)') + harness.unmount() + resolveRequest(result(1)) + await request + await harness.evaluate>('loadCustomers(true)') + expect(harness.evaluate('customers.value.length')).toBe(0) + expect(harness.requests).toHaveBeenCalledTimes(1) + }) + + it('失去权限后清空旧数据并拒绝加载', async () => { + const harness = createHarness() + harness.requests.mockResolvedValueOnce(result(1)) + await harness.evaluate>('loadCustomers(true)') + harness.features.allowed = false + await harness.evaluate>('loadCustomers(true)') + expect(harness.evaluate('customers.value.length')).toBe(0) + expect(harness.evaluate('total.value')).toBe(0) + expect(harness.evaluate('noPermission.value')).toBe(true) + expect(harness.requests).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/app/src/pages/business/views/customer/index.vue b/app/app/src/pages/business/views/customer/index.vue new file mode 100644 index 0000000..8132c4c --- /dev/null +++ b/app/app/src/pages/business/views/customer/index.vue @@ -0,0 +1,272 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) + throw new Error('未找到新建工单脚本') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2020 } }).outputText, context) + return (expression: string) => runInContext(expression, context) as Result +} + +describe('工单类型选择入口', () => { + it('使用可见单元格打开单列选择器', () => { + expect(source).toContain('title="工单类型"') + expect(source).toContain('@click="openTypePicker"') + expect(source).toContain('v-model:visible="typePickerOpen"') + expect(source).toContain(':columns="[problemTypes]"') + const evaluate = harness() + evaluate('openTypePicker()') + expect(evaluate('typePickerOpen.value')).toBe(true) + evaluate('formData.woType = [3]') + expect(evaluate('typeText.value')).toBe('订单问题') + }) + + it('关联业务及提交期间不允许更改类型', () => { + for (const guard of ['relatedId.value = 1', 'submitting.value = true', 'submitted.value = true']) { + const evaluate = harness() + evaluate(guard) + evaluate('openTypePicker()') + expect(evaluate('typePickerOpen.value')).toBe(false) + } + }) +}) + +describe('新建工单表单与提交', () => { + it('默认填入账号手机号,允许修改后随工单提交', async () => { + const create = vi.fn().mockResolvedValue({ workOrder: {} }) + const evaluate = harness(create) + expect(source).toContain('>('handleSubmit()') + expect(create).toHaveBeenCalledWith(expect.objectContaining({ creatorPhone: '13900009000' })) + expect(evaluate('userStore.userInfo.phone')).toBe('13800008000') + }) + + it.each(['', ' ', '123', '12800138000', '138001380000', '1380013800a'])('阻止无效手机号:%s', async (phone) => { + const create = vi.fn() + const evaluate = harness(create) + evaluate(`formData.title = "标题"; formData.content = "描述"; formData.creatorPhone = ${JSON.stringify(phone)}`) + await evaluate>('handleSubmit()') + expect(create).not.toHaveBeenCalled() + expect(evaluate('submitting.value')).toBe(false) + }) + + it('标题通过表单项显示,提交成功后禁止修改附件', () => { + expect(source).toContain(' { + const create = vi.fn().mockResolvedValue({ workOrder: { woNo: 'WO-1' } }) + const evaluate = harness(create) + evaluate(`formData.woType = [${type}]; formData.title = ' 标题 '; formData.content = ' 描述 '; attachmentUrls.value = ['file.pdf']`) + await evaluate>('handleSubmit()') + expect(create).toHaveBeenCalledWith({ woType: type, title: '标题', content: '描述', creatorPhone: '13800008000', attachmentUrls: ['file.pdf'], relatedId: undefined }) + await evaluate>('handleSubmit()') + expect(create).toHaveBeenCalledTimes(1) + }) + + it.each(['formData.woType = []', 'formData.woType = [99]', 'formData.title = " "', 'formData.content = " "', 'uploading.value = true'])('阻止无效提交:%s', async (condition) => { + const create = vi.fn() + const evaluate = harness(create) + evaluate('formData.title = "标题"; formData.content = "描述"') + evaluate(condition) + await evaluate>('handleSubmit()') + expect(create).not.toHaveBeenCalled() + }) + + it('提交失败保留表单并可重试', async () => { + const create = vi.fn().mockRejectedValueOnce(new Error('network')).mockResolvedValue({ workOrder: {} }) + const evaluate = harness(create) + evaluate('formData.title = "标题"; formData.content = "描述"; attachmentUrls.value = ["file.pdf"]') + await evaluate>('handleSubmit()') + expect(evaluate('formData.title')).toBe('标题') + expect(evaluate('attachmentUrls.value.length')).toBe(1) + expect(evaluate('submitting.value')).toBe(false) + expect(evaluate('submitted.value')).toBe(false) + await evaluate>('handleSubmit()') + expect(create).toHaveBeenCalledTimes(2) + }) +}) diff --git a/app/app/src/pages/business/views/workOrderCreate/index.vue b/app/app/src/pages/business/views/workOrderCreate/index.vue new file mode 100644 index 0000000..d7a549b --- /dev/null +++ b/app/app/src/pages/business/views/workOrderCreate/index.vue @@ -0,0 +1,252 @@ + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1]?.replace(/\/\/ #ifdef H5[\s\S]*?\/\/ #endif/g, '').replace(/\/\/ #ifndef MP-WEIXIN[\s\S]*?\/\/ #endif/g, '') + if (!script) + throw new Error('未找到工单详情脚本') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2020 } }).outputText, context) + runInContext('woNo.value = \'WO-1\'; workOrder.value = { actions: [\'reply\'], communications: [] }', context) + return { act, detail, fetchUserInfo, lifecycle, setInterval, clearInterval, fileManager, downloadFile, openDocument, evaluate: (expression: string) => runInContext(expression, context) as Result } +} + +describe('工单首次加载状态', () => { + it('权限和详情请求完成前持续展示骨架屏', async () => { + const page = harness() + page.evaluate('workOrder.value = undefined') + expect(page.evaluate('loading.value')).toBe(true) + let finishUser!: () => void + let finishDetail!: (value: unknown) => void + page.fetchUserInfo.mockImplementationOnce(() => new Promise((resolve) => { + finishUser = resolve + })) + page.detail.mockImplementationOnce(() => new Promise((resolve) => { + finishDetail = resolve + })) + const showing = page.lifecycle.show() + expect(page.evaluate('loading.value')).toBe(true) + expect(page.detail).not.toHaveBeenCalled() + finishUser() + await vi.waitFor(() => expect(page.detail).toHaveBeenCalledOnce()) + expect(page.evaluate('loading.value')).toBe(true) + finishDetail({ workOrder: { actions: ['reply'], communications: [] } }) + await showing + expect(page.evaluate('loading.value')).toBe(false) + expect(page.evaluate('workOrder.value')).toBeDefined() + }) + + it('详情请求真正失败后仍可重试', async () => { + const page = harness() + page.evaluate('workOrder.value = undefined') + page.detail.mockRejectedValueOnce(new Error('network')) + await page.lifecycle.show() + expect(page.evaluate('loading.value')).toBe(false) + expect(page.evaluate('workOrder.value')).toBeUndefined() + await page.evaluate>('loadDetail()') + expect(page.evaluate('workOrder.value')).toBeDefined() + expect(page.evaluate('loading.value')).toBe(false) + }) + + it('权限不足时结束加载并显示无权限提示', async () => { + const page = harness() + page.evaluate('workOrder.value = undefined; userStore.userInfo.revoked = true') + await page.lifecycle.show() + expect(page.evaluate('noPermission.value')).toBe(true) + expect(page.evaluate('loading.value')).toBe(false) + expect(page.detail).not.toHaveBeenCalled() + }) + + it('通知缺少工单编号时结束加载', async () => { + const page = harness() + page.evaluate('workOrder.value = undefined; woNo.value = ""') + await page.lifecycle.show() + expect(page.evaluate('loading.value')).toBe(false) + expect(page.detail).not.toHaveBeenCalled() + }) + + it('刷新权限时保留已有工单内容', async () => { + const page = harness() + await page.lifecycle.show() + expect(page.evaluate('loading.value')).toBe(false) + expect(page.evaluate('workOrder.value')).toBeDefined() + }) +}) + +describe('工单对话回复', () => { + it('发送后保持窗口打开,清空草稿并允许继续发送', async () => { + const page = harness() + page.evaluate('showConversation.value = true; replyContent.value = \'第一条\'') + await page.evaluate>('performAction(\'reply\')') + expect(page.evaluate('showConversation.value')).toBe(true) + expect(page.evaluate('replyContent.value')).toBe('') + expect(page.evaluate('conversationTarget.value')).toBe('conversation-bottom') + page.evaluate('replyContent.value = \'第二条\'') + await page.evaluate>('performAction(\'reply\')') + expect(page.act).toHaveBeenCalledTimes(2) + }) + + it('发送失败保留文字和附件草稿', async () => { + const page = harness() + page.act.mockRejectedValueOnce(new Error('network')) + page.evaluate('replyContent.value = \'待发送\'; replyAttachments.value = [\'file.pdf\']') + await page.evaluate>('performAction(\'reply\')') + expect(page.evaluate('replyContent.value')).toBe('待发送') + expect(page.evaluate('replyAttachments.value.length')).toBe(1) + expect(page.evaluate('sending.value')).toBe(false) + }) + + it('禁止空消息、上传期间发送和无权限回复', async () => { + const page = harness() + await page.evaluate>('performAction(\'reply\')') + page.evaluate('replyContent.value = \'消息\'; uploading.value = true') + await page.evaluate>('performAction(\'reply\')') + page.evaluate('uploading.value = false; workOrder.value.actions = []') + await page.evaluate>('performAction(\'reply\')') + expect(page.act).not.toHaveBeenCalled() + }) +}) + +describe('工单评价与刷新', () => { + it('评价使用独立草稿,不把未发送的沟通内容当成评价', async () => { + const page = harness() + page.evaluate('workOrder.value.actions = ["evaluate"]; replyContent.value = "未发出的沟通"; evaluationContent.value = "服务很好"') + await page.evaluate>('performAction("evaluate")') + expect(page.act).toHaveBeenCalledWith(expect.objectContaining({ action: 'evaluate', content: '服务很好', satisfactionLevel: 5 })) + expect(page.evaluate('replyContent.value')).toBe('未发出的沟通') + expect(page.evaluate('evaluationContent.value')).toBe('') + }) + + it('评价失败保留评价草稿,无效评分不提交', async () => { + const page = harness() + page.act.mockRejectedValueOnce(new Error('network')) + page.evaluate('workOrder.value.actions = ["evaluate"]; evaluationContent.value = "评价草稿"') + await page.evaluate>('performAction("evaluate")') + expect(page.evaluate('evaluationContent.value')).toBe('评价草稿') + for (const rating of [0, 6, 4.5]) { + page.evaluate(`workOrder.value.actions = ["evaluate"]; rating.value = ${rating}`) + await page.evaluate>('performAction("evaluate")') + } + expect(page.act).toHaveBeenCalledTimes(1) + }) + + it('操作成功后等待旧轮询结束,再获取最新详情', async () => { + const page = harness() + let finish!: (value: unknown) => void + page.detail.mockImplementationOnce(() => new Promise((resolve) => { + finish = resolve + })) + page.detail.mockResolvedValueOnce({ workOrder: { actions: ['reply'], currentStatus: 2, communications: [{ id: 2, content: '新回复' }] } }) + const polling = page.evaluate>('loadDetail(true)') + page.evaluate('replyContent.value = "新回复"') + const sending = page.evaluate>('performAction("reply")') + await vi.waitFor(() => expect(page.act).toHaveBeenCalledTimes(1)) + expect(page.detail).toHaveBeenCalledTimes(1) + finish({ workOrder: { actions: ['reply'], currentStatus: 1, communications: [] } }) + await Promise.all([polling, sending]) + expect(page.detail).toHaveBeenCalledTimes(2) + expect(page.evaluate('workOrder.value.currentStatus')).toBe(2) + expect(page.evaluate('workOrder.value.communications[0].content')).toBe('新回复') + }) +}) + +describe('工单附件原名展示', () => { + it('正文和消息附件都显示名称,不再使用附件序号', () => { + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + expect(source.match(/attachmentName\(url\)/g)?.length).toBe(2) + expect(source).not.toContain('附件 {{ index + 1 }}') + const page = harness() + page.evaluate('workOrder.value.attachmentNames = { "random.pdf": "排查说明.pdf" }') + expect(page.evaluate('attachmentName("random.pdf")')).toBe('排查说明.pdf') + expect(page.evaluate('attachmentName("old.xlsx")')).toBe('old.xlsx') + }) + + it('加载历史和轮询刷新后仍保留历史附件原名', async () => { + const page = harness() + page.evaluate('workOrder.value.communications = [{ id: 20 }]; hasMoreHistory.value = true') + page.detail.mockResolvedValueOnce({ workOrder: { communications: [{ id: 10 }], attachmentNames: { 'old.pdf': '历史资料.pdf' } } }) + await page.evaluate>('loadHistory()') + page.detail.mockResolvedValueOnce({ workOrder: { actions: ['reply'], communications: [{ id: 21 }], attachmentNames: { 'new.xlsx': '新资料.xlsx' } } }) + await page.evaluate>('loadDetail(true)') + expect(page.evaluate('attachmentName("old.pdf")')).toBe('历史资料.pdf') + expect(page.evaluate('attachmentName("new.xlsx")')).toBe('新资料.xlsx') + expect(page.evaluate('workOrder.value.communications.length')).toBe(3) + }) +}) + +describe('工单问题描述布局', () => { + it('分开展示文字说明与附件,并在内容为空时显示提示', () => { + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + const start = source.indexOf('问题描述') + const end = source.indexOf('', start) + const description = source.slice(start, end) + expect(description).toContain('文字说明') + expect(description).toContain('相关附件') + expect(description).toContain('break-words') + expect(description).not.toContain('break-all') + expect(description).toContain('暂无问题描述') + expect(description).toContain('{{ workOrder.attachmentUrls.length }} 个附件') + }) +}) + +describe('工单附件文件名预览', () => { + it('微信打开的文件使用工单展示的原始名称,返回后清理本地副本', async () => { + const page = harness() + page.evaluate('workOrder.value.attachmentNames = { "https://files/random": "现场照片.pdf" }') + page.evaluate('openAttachment("https://files/random")') + page.evaluate('uni.downloadFile.mock.calls[0][0].success({ statusCode: 200, tempFilePath: "/tmp/random.pdf" })') + expect(page.fileManager.copyFile).toHaveBeenCalledWith(expect.objectContaining({ + srcPath: '/tmp/random.pdf', + destPath: expect.stringMatching(/\/现场照片\.pdf$/), + })) + expect(page.openDocument).toHaveBeenCalledWith(expect.objectContaining({ filePath: expect.stringMatching(/\/现场照片\.pdf$/) })) + page.lifecycle.hide() + await page.lifecycle.show() + expect(page.fileManager.unlink).toHaveBeenCalledWith(expect.objectContaining({ filePath: expect.stringMatching(/\/现场照片\.pdf$/) })) + expect(page.fileManager.rmdir).toHaveBeenCalledWith(expect.objectContaining({ recursive: true })) + }) +}) + +describe('工单照片预览', () => { + it('正文和沟通附件复用缩略图,原名为照片的随机地址也使用图片预览', () => { + const page = harness() + page.evaluate('workOrder.value.attachmentNames = { "/upload/random": "现场照片.JPG" }') + page.evaluate('openAttachment("/upload/random")') + expect(page.evaluate('uni.previewImage.mock.calls[0][0]')).toEqual({ + urls: ['https://api.example.com/upload/random'], + current: 'https://api.example.com/upload/random', + }) + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + expect(source.match(/ { + it('预览引起隐藏和重新显示时,保留窗口、草稿及阅读位置并恢复轮询', async () => { + const page = harness() + await page.lifecycle.show() + page.evaluate('showConversation.value = true; replyContent.value = "未发送的内容"; replyAttachments.value = ["photo.jpg"]; conversationTarget.value = "history-message"') + page.evaluate('openAttachment("photo.jpg")') + page.lifecycle.hide() + expect(page.clearInterval).toHaveBeenCalledWith(1) + expect(page.evaluate('showConversation.value')).toBe(true) + await page.lifecycle.show() + expect(page.evaluate('showConversation.value')).toBe(true) + expect(page.evaluate('replyContent.value')).toBe('未发送的内容') + expect(page.evaluate('replyAttachments.value')).toEqual(['photo.jpg']) + expect(page.evaluate('conversationTarget.value')).toBe('history-message') + expect(page.setInterval).toHaveBeenCalledTimes(2) + page.lifecycle.unload() + expect(page.evaluate('visible')).toBe(false) + expect(page.evaluate('refreshTimer')).toBeUndefined() + }) + + it('从正文预览返回或手动关闭窗口后,不会自动打开沟通', async () => { + const page = harness() + page.evaluate('openAttachment("photo.jpg")') + page.lifecycle.hide() + await page.lifecycle.show() + expect(page.evaluate('showConversation.value')).toBe(false) + page.evaluate('showConversation.value = true; showConversation.value = false') + page.lifecycle.hide() + await page.lifecycle.show() + expect(page.evaluate('showConversation.value')).toBe(false) + }) + + it('重新显示时若失去查看权限,关闭沟通窗口并停止轮询', async () => { + const page = harness() + await page.lifecycle.show() + page.evaluate('showConversation.value = true') + page.lifecycle.hide() + page.evaluate('userStore.userInfo.revoked = true') + await page.lifecycle.show() + expect(page.evaluate('showConversation.value')).toBe(false) + expect(page.evaluate('noPermission.value')).toBe(true) + expect(page.evaluate('refreshTimer')).toBeUndefined() + }) +}) diff --git a/app/app/src/pages/business/views/workOrderDetail/index.vue b/app/app/src/pages/business/views/workOrderDetail/index.vue new file mode 100644 index 0000000..5434d95 --- /dev/null +++ b/app/app/src/pages/business/views/workOrderDetail/index.vue @@ -0,0 +1,638 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + + 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 @@ + + + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) + throw new Error('Login page script not found') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context) + runInContext('agreementChecked.value = true; promptBindWechatIfNeeded = async () => undefined', context) + return { context, login, phoneSmsLogin, setTokenInfo, setUserInfo, navigator, navigate } +} + +describe('登录页面成功后的导航', () => { + it('手机号密码登录显式绑定导航 API 并回到首页', async () => { + const harness = createHarness('/pages/user/index') + await runInContext('loginForm.account = "13800008000"; loginForm.password = "password"; submitLogin()', harness.context) + expect(harness.login).toHaveBeenCalledOnce() + expect(harness.navigator.reLaunch).toHaveBeenCalledWith({ url: home }) + expect(harness.navigator.switchTab).not.toHaveBeenCalled() + }) + + it('用户名密码登录保留来源页面', async () => { + const harness = createHarness('/pages/user/index') + await runInContext('loginForm.account = "agent01"; loginForm.password = "password"; submitLogin()', harness.context) + expect(harness.navigator.reLaunch).toHaveBeenCalledWith({ url: '/pages/user/index' }) + }) + + it('短信登录写入认证信息后回到首页', async () => { + const harness = createHarness('/pages/user/index') + await runInContext('loginSmsForm.phone = "13800008000"; loginSmsForm.smsCode = "123456"; submitPhoneSmsLogin()', harness.context) + expect(harness.phoneSmsLogin).toHaveBeenCalledOnce() + expect(harness.setTokenInfo).toHaveBeenCalledOnce() + expect(harness.setUserInfo).toHaveBeenCalledOnce() + expect(harness.navigator.reLaunch).toHaveBeenCalledWith({ url: home }) + }) + + + it('验证码多账号选择后仅登录选中的身份并回到首页', async () => { + const harness = createHarness('/pages/user/index') + const candidates = [{ userId: 11, nickName: '账号甲' }, { userId: 22, nickName: '账号乙' }] + const selectedResponse = { token: 'selected-token', expiresIn: 3600, userInfo: { userId: 22, nickName: '账号乙', wechatBound: true } } + harness.phoneSmsLogin.mockResolvedValueOnce({ needSelect: true, candidates }).mockResolvedValueOnce(selectedResponse) + await runInContext('loginMethod.value = "sms"; loginSmsForm.phone = "13800008000"; loginSmsForm.smsCode = "123456"; submitPhoneSmsLogin()', harness.context) + expect(runInContext('accountSelectVisible.value', harness.context)).toBe(true) + expect(harness.setTokenInfo).not.toHaveBeenCalled() + expect(harness.navigate).not.toHaveBeenCalled() + runInContext('selectLoginAccount(accountCandidates.value[1])', harness.context) + await vi.waitFor(() => expect(harness.navigator.reLaunch).toHaveBeenCalledWith({ url: home })) + expect(harness.phoneSmsLogin).toHaveBeenCalledTimes(2) + expect(harness.phoneSmsLogin).toHaveBeenNthCalledWith(2, { + tenantId: '000000', phone: '13800008000', smsCode: '123456', selectedUserId: 22, + }) + expect(harness.login).not.toHaveBeenCalled() + expect(harness.setTokenInfo).toHaveBeenCalledExactlyOnceWith(selectedResponse) + expect(harness.setUserInfo).toHaveBeenCalledExactlyOnceWith(selectedResponse.userInfo) + expect(harness.navigator.switchTab).not.toHaveBeenCalled() + expect(runInContext('accountSelectVisible.value', harness.context)).toBe(false) + expect(harness.navigator.showToast).not.toHaveBeenCalledWith(expect.objectContaining({ icon: 'none' })) + }) + + it('多账号选择请求失败时不设置登录态也不跳转', async () => { + const harness = createHarness() + harness.phoneSmsLogin.mockResolvedValueOnce({ needSelect: true, candidates: [{ userId: 22 }] }).mockRejectedValueOnce(new Error('选择的账号不属于当前手机号')) + await runInContext('loginMethod.value = "sms"; loginSmsForm.phone = "13800008000"; loginSmsForm.smsCode = "123456"; submitPhoneSmsLogin()', harness.context) + runInContext('selectLoginAccount(accountCandidates.value[0])', harness.context) + await vi.waitFor(() => expect(harness.navigator.showToast).toHaveBeenCalledWith({ title: '选择的账号不属于当前手机号', icon: 'none' })) + expect(harness.setTokenInfo).not.toHaveBeenCalled() + expect(harness.navigate).not.toHaveBeenCalled() + }) + + it('需要选择身份时不提前跳转', async () => { + const harness = createHarness() + harness.login.mockResolvedValue({ needSelect: true, candidates: [{ userId: 1 }] }) + await runInContext('loginForm.account = "13800008000"; loginForm.password = "password"; submitLogin()', harness.context) + expect(harness.navigate).not.toHaveBeenCalled() + expect(runInContext('accountSelectVisible.value', harness.context)).toBe(true) + }) +}) diff --git a/app/app/src/pages/login/index.vue b/app/app/src/pages/login/index.vue new file mode 100644 index 0000000..c8afb42 --- /dev/null +++ b/app/app/src/pages/login/index.vue @@ -0,0 +1,1661 @@ + + + + + + + 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 @@ + + + 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 @@ + + + + + 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(//gi, '') + .replace(//gi, '') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/\s+/g, ' ') + .trim() +} + +function formatMessageTime(value: string) { + if (!value) + return '' + + return String(value) + .replace('T', ' ') + .replace(/\.\d.*$/, '') + .slice(0, 16) +} diff --git a/app/app/src/pages/message/index.test.ts b/app/app/src/pages/message/index.test.ts new file mode 100644 index 0000000..ecad319 --- /dev/null +++ b/app/app/src/pages/message/index.test.ts @@ -0,0 +1,82 @@ +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 data from './data' +import * as pagination from './pagination' + +function createHarness() { + const tokenStore = { validToken: 'account-a' } + const setUnreadCount = vi.fn() + const getMessageList = vi.fn() + const readMessages = vi.fn(async () => undefined) + let resetAccount: () => void = () => undefined + const context = createContext({ + exports: {}, + require: (name: string) => { + if (name === '@/api/message') + return { getMessageList, readMessages } + if (name === '@/store/token') + return { useTokenStore: () => tokenStore } + if (name === '@/store/message') + return { useMessageStore: () => ({ setUnreadCount, adjustUnreadCount: vi.fn(), refreshUnreadCount: vi.fn() }) } + if (name === './data') + return data + if (name === './pagination') + return pagination + return { default: {} } + }, + ref: (value: unknown) => ({ value }), + computed: (getter: () => unknown) => ({ get value() { return getter() } }), + watch: (_getter: unknown, callback: () => void) => { resetAccount = callback }, + definePage: () => undefined, + onLoad: () => undefined, + onShow: () => undefined, + onUnload: () => undefined, + uni: { showToast: vi.fn(), navigateTo: vi.fn() }, + }) + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + const script = source.slice(source.indexOf('>') + 1, source.indexOf('')) + if (!script) + throw new Error('Message page script not found') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context) + return { + getMessageList, + readMessages, + setUnreadCount, + evaluate: (code: string) => runInContext(code, context), + switchAccount: () => { + tokenStore.validToken = 'account-b' + resetAccount() + }, + } +} + +describe('消息页面请求隔离', () => { + it('切换账号后丢弃旧账号列表响应与未读数量', async () => { + const harness = createHarness() + let resolvePage!: (value: unknown) => void + harness.getMessageList.mockReturnValueOnce(new Promise((resolve) => { + resolvePage = resolve + })) + const pending = harness.evaluate('loadMessages()') + harness.switchAccount() + resolvePage({ items: [{ id: 1, messageId: 1 }], total: 1 }) + await pending + expect(harness.evaluate('rawMessages.value.length')).toBe(0) + expect(harness.setUnreadCount).not.toHaveBeenCalled() + expect(harness.getMessageList).toHaveBeenCalledTimes(1) + }) + + it('批量已读期间切换账号后停止后续批次', async () => { + const harness = createHarness() + harness.evaluate('activeModuleKey.value = \'system\'') + harness.evaluate('rawMessages.value = Array.from({ length: 101 }, (_, index) => ({ id: index + 1, messageId: index + 1, messageType: 1, isRead: 0 }))') + harness.readMessages.mockImplementationOnce(async () => { + harness.switchAccount() + }) + await harness.evaluate('handleReadAll()') + expect(harness.readMessages).toHaveBeenCalledTimes(1) + expect(harness.setUnreadCount).not.toHaveBeenCalled() + }) +}) diff --git a/app/app/src/pages/message/index.vue b/app/app/src/pages/message/index.vue new file mode 100644 index 0000000..99915fb --- /dev/null +++ b/app/app/src/pages/message/index.vue @@ -0,0 +1,389 @@ + + + diff --git a/app/app/src/pages/message/pagination.test.ts b/app/app/src/pages/message/pagination.test.ts new file mode 100644 index 0000000..74e99ca --- /dev/null +++ b/app/app/src/pages/message/pagination.test.ts @@ -0,0 +1,25 @@ +import type { AppMessage } from '@/api/message' +import { describe, expect, it, vi } from 'vitest' +import { collectMessagePages } from './pagination' + +function item(id: number): AppMessage { + return { id, messageId: id, messageLogId: id, messageType: 1, title: '通知', content: '', isRead: 0 } +} + +describe('消息分页完整性', () => { + it('超过 100 条仍能加载完整列表', async () => { + const fetch = vi.fn().mockResolvedValueOnce({ items: Array.from({ length: 100 }, (_, i) => item(i + 1)), total: 101 }).mockResolvedValueOnce({ items: [item(101)], total: 101 }) + expect((await collectMessagePages(fetch))?.length).toBe(101) + expect(fetch).toHaveBeenCalledTimes(2) + }) + it('过期请求不返回旧数据', async () => { + expect(await collectMessagePages(vi.fn().mockResolvedValue({ items: [item(1)], total: 1 }), {}, () => false)).toBeNull() + }) + it('重复分页不造成无限加载', async () => { + const fetch = vi.fn().mockResolvedValue({ items: Array.from({ length: 100 }, (_, i) => item(i + 1)), total: 300 }) + await expect(collectMessagePages(fetch)).rejects.toThrow('分页未前进') + }) + it('分页失败向调用者传播,不假装加载完整', async () => { + await expect(collectMessagePages(vi.fn().mockRejectedValue(new Error('网络失败')))).rejects.toThrow('网络失败') + }) +}) diff --git a/app/app/src/pages/message/pagination.ts b/app/app/src/pages/message/pagination.ts new file mode 100644 index 0000000..c44d362 --- /dev/null +++ b/app/app/src/pages/message/pagination.ts @@ -0,0 +1,22 @@ +import type { AppMessage, AppMessageListParams, AppMessageListRes } from '@/api/message' + +export async function collectMessagePages( + fetchPage: (params: AppMessageListParams) => Promise, + params: Omit = {}, + isCurrent: () => boolean = () => true, +): Promise { + const messages = new Map() + for (let page = 1; ; page++) { + const result = await fetchPage({ ...params, page, pageSize: 100 }) + if (!isCurrent()) + return null + const items = result.items || [] + const previousSize = messages.size + for (const item of items) + messages.set(Number(item.messageId || item.id), item) + if (items.length < 100 || page * 100 >= Number(result.total || 0)) + return Array.from(messages.values()) + if (messages.size === previousSize) + throw new Error('消息分页未前进,请稍后重试') + } +} diff --git a/app/app/src/pages/message/views/cardDetail/index.vue b/app/app/src/pages/message/views/cardDetail/index.vue new file mode 100644 index 0000000..2be4dd3 --- /dev/null +++ b/app/app/src/pages/message/views/cardDetail/index.vue @@ -0,0 +1,250 @@ + + + diff --git a/app/app/src/pages/message/views/systemDetail/index.vue b/app/app/src/pages/message/views/systemDetail/index.vue new file mode 100644 index 0000000..2be4dd3 --- /dev/null +++ b/app/app/src/pages/message/views/systemDetail/index.vue @@ -0,0 +1,250 @@ + + + diff --git a/app/app/src/pages/user/components/UserEntryGroup.vue b/app/app/src/pages/user/components/UserEntryGroup.vue new file mode 100644 index 0000000..10537ee --- /dev/null +++ b/app/app/src/pages/user/components/UserEntryGroup.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/app/src/pages/user/components/UserProfileCard.vue b/app/app/src/pages/user/components/UserProfileCard.vue new file mode 100644 index 0000000..7feb465 --- /dev/null +++ b/app/app/src/pages/user/components/UserProfileCard.vue @@ -0,0 +1,204 @@ + + + + + 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 @@ + + + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) + throw new Error('User page script not found') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context) + return { + visible: () => runInContext('canUseCustomerManagement.value', context), + workOrderVisible: () => runInContext('canUseWorkOrder.value', context), + openWorkOrder: () => runInContext('openWorkOrder()', context), + open: () => runInContext('openCustomerManagement()', context), + navigateTo, + source, + } +} + +describe('客户管理验证入口', () => { + it('工单入口按权限选择列表或直接提交', () => { + for (const [feature, url] of [['workorder:list', RouterPath.BusinessViewsWorkOrder], ['workorder:create', RouterPath.BusinessViewsWorkOrderCreate]]) { + const harness = createHarness({ userId: 12, appAccess: { configured: true, features: [feature] } }) + expect(harness.workOrderVisible()).toBe(true) + harness.openWorkOrder() + expect(harness.navigateTo).toHaveBeenCalledWith({ url }) + } + const denied = createHarness({ userId: 12, appAccess: { configured: true, features: [] } }) + expect(denied.workOrderVisible()).toBe(false) + denied.openWorkOrder() + expect(denied.navigateTo).not.toHaveBeenCalled() + }) + it('有客户权限的企业账号可从我的进入客户管理', () => { + const harness = createHarness({ userId: 12, userType: 'sys_user', appAccess: { configured: true, features: ['customer:list'] } }) + expect(harness.visible()).toBe(true) + expect(harness.source).toContain('v-if="canUseCustomerManagement"') + expect(harness.source).toContain('title="客户管理"') + harness.open() + expect(harness.navigateTo).toHaveBeenCalledWith({ url: RouterPath.BusinessViewsCustomer }) + }) + + it('无权限企业和终端账号不显示入口', () => { + expect(createHarness({ userId: 12, userType: 'sys_user', appAccess: { configured: true, features: [] } }).visible()).toBe(false) + expect(createHarness({ userId: 13, userType: 'app_user', appAccess: { features: ['customer:list'] } }).visible()).toBe(false) + }) + + it('客户与划拨已开放,其他未发布业务继续隐藏', () => { + expect(isV101HiddenRoute(RouterPath.BusinessViewsCustomer)).toBe(false) + expect(isV101HiddenRoute(RouterPath.BusinessViewsCustomerDetail)).toBe(false) + expect(isV101HiddenRoute(RouterPath.BusinessViewsAllocate)).toBe(false) + expect(isV101HiddenRoute('/pages/business/index')).toBe(false) + expect(V101_RELEASE.showBusinessTab).toBe(true) + expect(V101_RELEASE.showUserBusinessEntries).toBe(false) + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + expect(source).toContain('') + expect(source).not.toContain('v-if="V101_RELEASE.showUserBusinessEntries"') + }) +}) diff --git a/app/app/src/pages/user/index.vue b/app/app/src/pages/user/index.vue new file mode 100644 index 0000000..dd6caa2 --- /dev/null +++ b/app/app/src/pages/user/index.vue @@ -0,0 +1,191 @@ + + + + + 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 @@ + + + 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(/]*>([\s\S]*?)<\/script>/)?.[1] + if (!script) + throw new Error('Log page script not found') + runInContext(transpileModule(script, { compilerOptions: { module: ModuleKind.CommonJS } }).outputText, context) + return { + userInfo, + list, + summary, + details, + allocateSummary, + allocateDetails, + evaluate: (expression: string) => runInContext(expression, context) as Result, + hide: () => hideCallbacks.forEach(callback => callback()), + unload: () => unloadCallbacks.forEach(callback => callback()), + } +} + +describe('批次日志查询', () => { + it('用户分包不直接导入业务分包,避免小程序详情脚本加载失败', () => { + const source = readFileSync(new URL('../executionLogDetail/index.vue', import.meta.url), 'utf8') + expect(source).not.toContain('@/pages/business/') + expect(source).toContain('from \'@/utils/allocateStatus\'') + }) + + it('概览失败时仍显示已取得的卡片明细', async () => { + const page = harness(true) + page.evaluate('batchNo.value = \'A\'') + page.summary.mockRejectedValue(new Error('summary unavailable')) + page.details.mockResolvedValue({ items: [{ itemId: 'CARD' }], total: 1 }) + await page.evaluate>('load(true)') + expect(page.evaluate('items.value[0].itemId')).toBe('CARD') + expect(page.evaluate('error.value')).toBe('批次概览加载失败,请刷新重试') + }) + + it('批次类型与后端编码一致并兼容历史类型', () => { + expect(helpers.executionBatchTypeLabel(3)).toBe('卡片划拨') + expect(helpers.executionBatchTypeLabel(101)).toBe('卡片续费') + expect(helpers.executionBatchTypeLabel(105)).toBe('重启') + expect(helpers.executionBatchTypeLabel(109)).toBe('开通数据服务') + expect(helpers.executionBatchTypeLabel(110)).toBe('停用数据服务') + expect(helpers.executionBatchTypeLabel(999)).toBe('其他批次') + }) + + it('筛选使用紧凑双列,重置移至顶部操作区', () => { + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + expect(source).toContain('grid grid-cols-2 mt-20rpx gap-12rpx') + expect(source.indexOf('@click="resetFilters"')).toBeLessThan(source.indexOf('v-if="filtersOpen"')) + expect(source).toContain('executionBatchTypeLabel(item.batchType)') + }) + + it('无列表权限不发请求并清空旧记录', async () => { + const page = harness() + page.userInfo.accessCodes = [] + await page.evaluate>('load(true)') + expect(page.list).not.toHaveBeenCalled() + expect(page.evaluate('items.value.length')).toBe(0) + }) + it('概览和明细分别遵守控制台权限', async () => { + const page = harness(true) + page.evaluate('batchNo.value = \'A\'') + page.userInfo.accessCodes = ['cpm:common:batchExecutionLog:summary'] + page.summary.mockResolvedValue({ batchNo: 'A' }) + await page.evaluate>('load(true)') + expect(page.summary).toHaveBeenCalledTimes(1) + expect(page.details).not.toHaveBeenCalled() + page.userInfo.accessCodes = ['cpm:common:batchExecutionLog:detailList'] + page.details.mockResolvedValue({ items: [{ itemId: '1' }], total: 1 }) + await page.evaluate>('load(true)') + expect(page.summary).toHaveBeenCalledTimes(1) + expect(page.details).toHaveBeenCalledTimes(1) + expect(page.evaluate('summary.value')).toBeUndefined() + }) + + it('明细入口绑定在原生 view 上,不依赖卡片组件事件', () => { + const source = readFileSync(new URL('./index.vue', import.meta.url), 'utf8') + expect(source).toContain('') + expect(source).not.toContain('custom-content-class="!p-28rpx" @click="details(item)"') + }) + + it('默认收起筛选,分页使用已提交条件', async () => { + const page = harness() + expect(page.evaluate('filtersOpen.value')).toBe(false) + page.list.mockResolvedValueOnce({ items: [{ batchNo: 'A' }], total: 3 }).mockResolvedValueOnce({ items: [{ batchNo: 'B' }], total: 3 }) + page.evaluate('keyword.value = \'原始\'; channel.value = [\'console\']') + await page.evaluate>('load(true)') + page.evaluate('keyword.value = \'未提交\'; channel.value = [\'api\']') + await page.evaluate>('load()') + expect(page.list.mock.calls[1][0]).toMatchObject({ page: 2, batchNo: '原始', sourceChannel: 'console' }) + }) + + it('分页失败后重试同一页,保留已加载记录', async () => { + const page = harness() + page.list.mockResolvedValueOnce({ items: [{ batchNo: 'A' }], total: 3 }).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ items: [{ batchNo: 'B' }], total: 3 }) + await page.evaluate>('load(true)') + await page.evaluate>('load()') + expect(page.evaluate('page.value')).toBe(1) + expect(page.evaluate('items.value.length')).toBe(1) + await page.evaluate>('load()') + expect(page.list.mock.calls[2][0].page).toBe(2) + }) + + it('旧请求不能覆盖新搜索结果', async () => { + const page = harness() + let finishOld!: (value: unknown) => void + page.list.mockImplementationOnce(() => new Promise((resolve) => { + finishOld = resolve + })).mockResolvedValueOnce({ items: [{ batchNo: 'NEW' }], total: 1 }) + const oldRequest = page.evaluate>('load(true)') + await page.evaluate>('load(true)') + finishOld({ items: [{ batchNo: 'OLD' }], total: 1 }) + await oldRequest + expect(page.evaluate('items.value[0].batchNo')).toBe('NEW') + }) + + it('隐藏和卸载后忽略未完成请求', async () => { + const page = harness() + let finish!: (value: unknown) => void + page.list.mockImplementationOnce(() => new Promise((resolve) => { + finish = resolve + })) + const request = page.evaluate>('load(true)') + page.hide() + expect(page.evaluate('loading.value')).toBe(false) + page.unload() + finish({ items: [{ batchNo: 'A' }], total: 1 }) + await request + await page.evaluate>('load(true)') + expect(page.evaluate('items.value.length')).toBe(0) + expect(page.list).toHaveBeenCalledTimes(1) + }) + + it('详情分页保留已提交的失败筛选', async () => { + const page = harness(true) + page.evaluate('batchNo.value = \'A\'; failures.value = true') + page.summary.mockResolvedValue({ batchNo: 'A' }) + page.details.mockResolvedValueOnce({ items: [{ itemId: '1' }], total: 3 }).mockResolvedValueOnce({ items: [{ itemId: '2' }], total: 3 }) + await page.evaluate>('load(true)') + page.evaluate('failures.value = false') + await page.evaluate>('load()') + expect(page.details.mock.calls[1][0]).toMatchObject({ page: 2, detailStatus: 3 }) + expect(page.summary).toHaveBeenCalledTimes(1) + expect(page.evaluate('infoOpen.value')).toBe(false) + }) + + it('明细请求不等待概览接口完成', async () => { + const page = harness(true) + page.evaluate('batchNo.value = \'A\'') + let resolveSummary!: (value: { batchNo: string }) => void + page.summary.mockReturnValue(new Promise((resolve) => { + resolveSummary = resolve + })) + page.details.mockResolvedValue({ items: [{ itemId: '1' }], total: 1 }) + const loading = page.evaluate>('load(true)') + expect(page.details).toHaveBeenCalledTimes(1) + resolveSummary({ batchNo: 'A' }) + await loading + expect(page.evaluate('items.value.length')).toBe(1) + }) + + it('日志入口识别划拨语义,但不切换日志权限接口', async () => { + const page = harness(true) + page.evaluate('batchNo.value = \'A\'') + page.summary.mockResolvedValue({ batchNo: 'A', batchType: 3, batchAsyncStatus: 2 }) + page.details.mockResolvedValue({ items: [{ itemId: '1', detailStatus: 2 }], total: 1 }) + await page.evaluate>('load(true)') + expect(page.evaluate('isAllocateTask.value')).toBe(true) + expect(allocateHelpers.allocateDetailStatus(page.evaluate('items.value[0].detailStatus'), page.evaluate('summary.value.batchAsyncStatus'))).toBe('校验通过 · 待整批完成') + expect(page.allocateSummary).not.toHaveBeenCalled() + expect(page.allocateDetails).not.toHaveBeenCalled() + }) + + it('划拨任务入口继续使用专用结果接口', async () => { + const page = harness(true) + page.evaluate('batchNo.value = \'A\'; allocateScene.value = true') + page.allocateSummary.mockResolvedValue({ batchNo: 'A', batchType: 3, batchAsyncStatus: 3 }) + page.allocateDetails.mockResolvedValue({ items: [{ itemId: '1', detailStatus: 2 }], total: 1 }) + await page.evaluate>('load(true)') + expect(page.allocateSummary).toHaveBeenCalledWith({ batchNo: 'A' }) + expect(page.summary).not.toHaveBeenCalled() + expect(page.details).not.toHaveBeenCalled() + expect(allocateHelpers.allocateDetailStatus(page.evaluate('items.value[0].detailStatus'), page.evaluate('summary.value.batchAsyncStatus'))).toBe('已划拨') + }) + + it('批次和明细状态使用各自的编码', () => { + expect(helpers.executionStatusType(3)).toBe('success') + expect(helpers.executionStatusType(4)).toBe('danger') + expect(helpers.executionStatusType(5)).toBe('warning') + expect(helpers.executionDetailStatusType(2)).toBe('success') + expect(helpers.executionDetailStatusType(3)).toBe('danger') + }) +}) diff --git a/app/app/src/pages/user/views/executionLog/index.vue b/app/app/src/pages/user/views/executionLog/index.vue new file mode 100644 index 0000000..8e0c023 --- /dev/null +++ b/app/app/src/pages/user/views/executionLog/index.vue @@ -0,0 +1,223 @@ + + + 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 @@ + + + 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 @@ + + +