ow
This commit is contained in:
1 parent
6a8258ac4b
commit
a7b25d49c4
29 files changed
+1960
-649
No files matched your search
@@ -0,0 +1,24 @@
|
||||
# ============================================================================
|
||||
# 全站共享的环境变量:Vite 先读 .env,再由 .env.[mode] 覆盖同名项
|
||||
#
|
||||
# 这里只放「换环境要动、换设备不用动」的后端根地址;品牌文案(Title/Copy/Beian)
|
||||
# 仍然留在 src/policy/custom/webSitePolicy.ts 的常量里,不进 .env。
|
||||
#
|
||||
# mode 由 uni 命令决定:
|
||||
# uni / uni -p mp-weixin / uni -p app => development => .env + .env.development
|
||||
# uni build / uni build -p xxx => production => .env + .env.production
|
||||
# 读取方:src/policy/custom/webSitePolicy.ts(变量缺失时回落到文件内的默认常量)
|
||||
# 类型声明:src/types/env.d.ts
|
||||
#
|
||||
# 前端产物人人可读,本文件禁止出现任何密钥或凭据。
|
||||
# 个人机器差异请写 .env.local / .env.development.local(已被 .gitignore 的 *.local 忽略)。
|
||||
# ============================================================================
|
||||
|
||||
# 发布构建使用的后端根地址(生产 / UAT 部署)
|
||||
VITE_WEB_API_ROOT=https://uat.youjiala.com
|
||||
|
||||
# 开发构建使用的后端根地址(H5 联调,默认连宿主机本地后端)
|
||||
VITE_WEB_API_ROOT_DEV=http://localhost:5235
|
||||
|
||||
# App 端开发构建使用的后端根地址:真机与模拟器上没有宿主机 localhost,改连局域网或 UAT
|
||||
VITE_WEB_API_ROOT_DEV_APP=https://uat.youjiala.com
|
||||
@@ -0,0 +1,4 @@
|
||||
# 开发构建(mode=development)专用覆盖:只列与 .env 不同的项,其余继承 .env。
|
||||
# 本机后端不跑在 5235、或要改连远程 dev 库时,改这里;也可以另建
|
||||
# .env.development.local 做个人覆盖,不要把本机地址提交上来。
|
||||
VITE_WEB_API_ROOT_DEV=http://localhost:5235
|
||||
@@ -0,0 +1,4 @@
|
||||
# 发布构建(mode=production)专用覆盖:只列与 .env 不同的项,其余继承 .env。
|
||||
# App 端发布同样连这个地址(发布构建不走 DEV_APP 那条条件编译分支)。
|
||||
# 换成正式生产域名时只改这一行,源码里的默认常量保留作 fallback。
|
||||
VITE_WEB_API_ROOT=https://uat.youjiala.com
|
||||
@@ -4,11 +4,12 @@ logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
dist
|
||||
docs
|
||||
*.local
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
shamefully-hoist=true
|
||||
@@ -1,50 +0,0 @@
|
||||
# 使用 pnpm 管理依赖
|
||||
|
||||
本项目为 pnpm 依赖管理。
|
||||
|
||||
## 常用命令
|
||||
|
||||
- 安装依赖:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
- 启动开发:
|
||||
|
||||
```bash
|
||||
# 运行h5
|
||||
pnpm dev:h5
|
||||
|
||||
# 运行微信小程序
|
||||
pnpm dev:mp-weixin
|
||||
|
||||
# 运行安卓APP
|
||||
pnpm dev:app-android
|
||||
```
|
||||
|
||||
- 构建生产包:
|
||||
|
||||
```bash
|
||||
# 运行h5
|
||||
pnpm build:h5
|
||||
|
||||
# 运行微信小程序
|
||||
pnpm build:mp-weixin
|
||||
|
||||
# 运行安卓APP
|
||||
pnpm build:app-android
|
||||
```
|
||||
|
||||
- 添加依赖:
|
||||
```bash
|
||||
pnpm add <包名>
|
||||
```
|
||||
- 删除依赖:
|
||||
```bash
|
||||
pnpm remove <包名>
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
- 已自动生成 `pnpm-lock.yaml`,请勿手动修改。
|
||||
- 如遇 node_modules 兼容问题,可先删除 node_modules 和 pnpm-lock.yaml 后重新 `pnpm install`。
|
||||
- 推荐全员统一使用 pnpm,避免包管理混乱。
|
||||
@@ -1,275 +0,0 @@
|
||||
<!-- Language Switch Button -->
|
||||
<p align="right" style="margin-top: 10px;">
|
||||
<span style="padding: 6px 16px; background: #ededed; color: #333; border-radius: 6px; margin-left: 8px; font-weight: bold;">English</span>
|
||||
<a href="README.md" style="padding: 6px 16px; background: #2979ff; color: #fff; border-radius: 6px; text-decoration: none; font-weight: bold;">中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="logo" src="https://ik.imagekit.io/anyup/uview-pro/common/logo-new.png" width="120" height="120" style="margin-bottom: 10px;">
|
||||
</p>
|
||||
<h3 align="center" style="margin: 30px 0 30px;font-weight: bold;font-size:40px;">uView Pro</h3>
|
||||
<h3 align="center">uni-app Vue3 Multi-platform Rapid Development UI Framework</h3>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://gitee.com/anyup/uView-Pro)
|
||||
[](https://gitee.com/anyup/uView-Pro)
|
||||
[](https://github.com/anyup/uView-Pro)
|
||||
[](https://github.com/anyup/uView-Pro)
|
||||
[](https://github.com/anyup/uView-Pro/issues)
|
||||
[](https://www.npmjs.com/package/uview-pro)
|
||||
[](https://uviewpro.cn)
|
||||
[](https://nodejs.org/)
|
||||
[](https://pnpm.io/)
|
||||
[](https://en.wikipedia.org/wiki/MIT_License)
|
||||
|
||||
</div>
|
||||
|
||||
## Description
|
||||
|
||||
uView UI is an excellent UI framework in the [uni-app](https://uniapp.dcloud.io/) ecosystem, providing comprehensive components and handy tools for efficient development.
|
||||
|
||||
uView Pro is a uni-app ecosystem framework that fully supports Vue3.0 and TypeScript. The baseline version of uView Pro is modified based on uView 1.8.8, completely reconstructed using TypeScript, and now fully supports uni-app Vue3.0.
|
||||
|
||||
## [Official Documentation: https://uviewpro.cn](https://uviewpro.cn)
|
||||
|
||||
## [Quick Start Template: https://starter.uviewpro.cn](https://starter.uviewpro.cn)
|
||||
|
||||
## Features
|
||||
|
||||
- Compatible with Android, iOS, WeChat Mini Programs, H5, QQ Mini Programs, Baidu Mini Programs, Alipay Mini Programs, and Toutiao Mini Programs
|
||||
- 70+ selected components, rich in functionality, multi-end compatibility, quick integration, and ready to use out of the box
|
||||
- Numerous handy JS tools for efficient development
|
||||
- A variety of commonly used pages and layouts, allowing you to focus on logic and achieve more with less effort
|
||||
- Detailed documentation and modern demo effects
|
||||
- On-demand import, streamlined bundle size
|
||||
|
||||
## HarmonyOS Preview
|
||||
|
||||
The uView Pro HarmonyOS application has officially been launched in the Huawei App Market, providing you with a complete business scenario demonstration platform. It includes a component library, template samples, scenario cases, and supports one-click copying and downloading to help developers quickly get started and experience the practical value of the components!
|
||||
|
||||
> System requirements: Only supported on HarmonyOS 5.0 and above devices
|
||||
|
||||
<table>
|
||||
<tr align="center">
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_harmony.png" width="180" height="180" ></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>HarmonyOS</strong><br>(Scan with browse)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Mobile Preview
|
||||
|
||||
You can scan the following QR codes with **WeChat** or **mobile browser** to view the best demo effect.
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_uview_pro_wx.jpg" width="150" height="150" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_uview_pro_alipay.png" width="150" height="150" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_uview_pro_h5.png" width="150" height="150" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_uview_pro_android.png" width="150" height="150" ></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>WeChat Mini Program</strong><br>(Scan with WeChat)</td>
|
||||
<td align="center"><strong>AliPay Mini Program</strong><br>(Scan with AliPay)</td>
|
||||
<td align="center"><strong>H5</strong><br>(Scan with browser)</td>
|
||||
<td align="center"><strong>Android</strong><br>(Scan with browser)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
To run the sample project, please [download the source code](https://github.com/anyup/uview-pro) and execute the following commands in the project root directory:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
For more running and building commands, please refer to the [pnpm Running Guide](README-pnpm.md)
|
||||
|
||||
## Links
|
||||
|
||||
- [Github](https://github.com/anyup/uview-pro)
|
||||
- [Gitee](https://gitee.com/anyup/uview-pro)
|
||||
- [Official Documentation](https://uviewpro.cn)
|
||||
- [Changelog](https://github.com/anyup/uView-Pro/blob/master/src/uni_modules/uview-pro/changelog.md)
|
||||
|
||||
## Communication & Feedback
|
||||
|
||||
uView Pro WeChat Group: [Click to Join](https://uviewpro.cn/zh/resource/about.html)
|
||||
|
||||
uView Pro QQ Group: [Click to Join](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=98nSVDldWEbDdq4lxiP4aL7uATfMSlI6&authKey=G2yQJ5MQiKzMldaxBsIfKt17NuJuUw8Fr6zdKLggc6NZXgw4BVbqkU2U3EE994yd&noverify=0&group_code=811732166)
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/weixin-chat-cl.png" width="250" height="345" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qq-chat.png" width="250" height="345" ></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>WeChat Group</strong><br></td>
|
||||
<td align="center"><strong>QQ Group</strong><br></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## About PR
|
||||
|
||||
We are very happy to accept high-quality PRs from everyone. However, please note that uView Pro needs to be compatible with multiple platforms (Mini Programs, H5, iOS App, Android App), including nvue and vue pages.
|
||||
|
||||
Therefore, before you fix bugs and submit PRs, please try your best to test compatibility on these platforms. It would be best to include test screenshots to facilitate review. Thank you very much!
|
||||
|
||||
## Installation And Configuration
|
||||
|
||||
uView Pro supports both `npm` and `uni_modules` installation methods, and the configuration is highly consistent. No matter which method you use, you can use easycom for automatic component import, greatly improving development efficiency. The following is a unified configuration guide:
|
||||
|
||||
### 1. Install uView Pro
|
||||
|
||||
- npm installation:
|
||||
|
||||
```bash
|
||||
npm install uview-pro
|
||||
# or
|
||||
yarn add uview-pro
|
||||
# or
|
||||
pnpm add uview-pro
|
||||
```
|
||||
|
||||
- uni_modules installation:
|
||||
|
||||
Download from HBuilderX plugin market or manually, and place uView Pro in the `uni_modules` directory.
|
||||
|
||||
[Plugin Market: https://ext.dcloud.net.cn/plugin?id=24633](https://ext.dcloud.net.cn/plugin?id=24633)
|
||||
|
||||
### 2. Import uView Pro main library
|
||||
|
||||
Import and register uView Pro in `main.ts`:
|
||||
|
||||
```js
|
||||
// main.ts
|
||||
import { createSSRApp } from 'vue';
|
||||
// npm method
|
||||
import uViewPro from 'uview-pro';
|
||||
// uni_modules method
|
||||
// import uViewPro from "@/uni_modules/uview-pro";
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
app.use(uViewPro);
|
||||
return {
|
||||
app
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Import global styles
|
||||
|
||||
Import theme styles in `uni.scss`:
|
||||
|
||||
```scss
|
||||
/* uni.scss */
|
||||
// npm method
|
||||
@import 'uview-pro/theme.scss';
|
||||
// uni_modules method
|
||||
// @import "@/uni_modules/uview-pro/theme.scss";
|
||||
```
|
||||
|
||||
Import base styles at the top of `App.vue`:
|
||||
|
||||
```scss
|
||||
<style lang="scss">
|
||||
// npm method
|
||||
@import "uview-pro/index.scss";
|
||||
// uni_modules method
|
||||
// @import "@/uni_modules/uview-pro/index.scss";
|
||||
</style>
|
||||
```
|
||||
|
||||
### 4. Configure easycom for automatic component import
|
||||
|
||||
Configure easycom rules in `pages.json` for automatic component import:
|
||||
|
||||
```json
|
||||
// pages.json
|
||||
{
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
// npm method
|
||||
"^u-(.*)": "uview-pro/components/u-$1/u-$1.vue"
|
||||
// uni_modules method
|
||||
// "^u-(.*)": "@/uni_modules/uview-pro/components/u-$1/u-$1.vue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
// ...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Tips**
|
||||
|
||||
- 1. After modifying the `easycom` rules, you need to restart HX or recompile the project.
|
||||
- 2. Please ensure there is only one easycom field in `pages.json`, otherwise please merge multiple rules yourself.
|
||||
- 3. It must be placed inside `custom`, otherwise it will not take effect.
|
||||
|
||||
### 5. Volar Type Support
|
||||
|
||||
For global Volar type support in CLI projects, add the following to `tsconfig.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
// npm method
|
||||
"types": ["uview-pro/types"]
|
||||
// uni_modules method
|
||||
// "types": ["@/uni_modules/uview-pro/types"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> HBuilderX projects do not support the types configuration in tsconfig.json. CLI projects are recommended to configure for the best TS experience.
|
||||
|
||||
### 6. Component Usage
|
||||
|
||||
After configuration, you can use uView Pro components directly in SFC without import or components registration:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<u-button type="primary">Button</u-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
See [Quick Start](https://uviewpro.cn/zh/components/quickstart.html) for more details.
|
||||
|
||||
## Donate to uView Pro
|
||||
|
||||
The documentation and framework source code of uView Pro are developed based on uView UI and are therefore open source and free. If you think uView Pro has helped your development work, you can donate to support uView Pro. There is no threshold for donation, even a cup of cola is appreciated (believe me, this is more meaningful than tipping a streamer).
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/wechat-pay.png" width="250" height="345" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/ali-pay.png" width="250" height="345" ></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>WeChat</strong><br></td>
|
||||
<td align="center"><strong>Alipay</strong><br></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/anyup/uView-Pro/graphs/contributors">
|
||||
<img alt="Contributors" src="https://contrib.rocks/image?repo=anyup/uView-Pro" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
uView Pro follows the [MIT](https://en.wikipedia.org/wiki/MIT_License) open source license, which means you do not need to pay any fees or obtain authorization to use uView Pro in your products.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Special thanks to the uView UI development team, all contributors to uView UI, and all contributors to uView Pro.
|
||||
|
||||
- [Github](https://github.com/anyup/uview-pro)
|
||||
- [Gitee](https://gitee.com/anyup/uview-pro)
|
||||
- [uView UI 1.0](https://github.com/umicro/uView)
|
||||
- [uView UI 2.0](https://github.com/umicro/uView2.0)
|
||||
+61
-246
@@ -1,272 +1,87 @@
|
||||
<!-- 语言切换按钮 -->
|
||||
<p align="right" style="margin-top: 10px;">
|
||||
<a href="README.en.md" style="padding: 6px 16px; background: #2979ff; color: #fff; border-radius: 6px; text-decoration: none; font-weight: bold;">English</a>
|
||||
<span style="padding: 6px 16px; background: #ededed; color: #333; border-radius: 6px; margin-left: 8px; font-weight: bold;">中文</span>
|
||||
</p>
|
||||
# 有家相册(uni-app 跨端前端)
|
||||
|
||||
<p align="center">
|
||||
<img alt="logo" src="https://ik.imagekit.io/anyup/uview-pro/common/logo-new.png" width="120" height="120" style="margin-bottom: 10px;">
|
||||
</p>
|
||||
<h3 align="center" style="margin: 30px 0 30px;font-weight: bold;font-size:40px;">uView Pro</h3>
|
||||
<h3 align="center">uni-app Vue3 多平台快速开发的 UI 框架</h3>
|
||||
家庭相册、家族树与文档共享的 uni-app 前端:一套源码出 H5、微信小程序、App 三端。
|
||||
业务后端是 CloudBuilder(.NET),本仓库的前身是旧管理端 WebVue 的迁移工程。
|
||||
|
||||
<div align="center">
|
||||
## 技术栈
|
||||
|
||||
[](https://gitee.com/anyup/uView-Pro)
|
||||
[](https://gitee.com/anyup/uView-Pro)
|
||||
[](https://github.com/anyup/uView-Pro)
|
||||
[](https://github.com/anyup/uView-Pro)
|
||||
[](https://github.com/anyup/uView-Pro/issues)
|
||||
[](https://www.npmjs.com/package/uview-pro)
|
||||
[](https://uviewpro.cn)
|
||||
[](https://nodejs.org/)
|
||||
[](https://pnpm.io/)
|
||||
[](https://en.wikipedia.org/wiki/MIT_License)
|
||||
Vue 3 + TypeScript + Vite + uni-app + uview-pro + Pinia + vue-i18n。
|
||||
**包管理器统一使用 npm**(脚手架附带的 pnpm 配置已移除,请勿提交 `pnpm-lock.yaml`)。
|
||||
|
||||
</div>
|
||||
## 环境要求
|
||||
|
||||
## 说明
|
||||
- Node >= 18
|
||||
- npm >= 9
|
||||
|
||||
`uView UI`,是 [uni-app](https://uniapp.dcloud.io/) 生态优秀的 UI 框架,全面的组件和便捷的工具会让您信手拈来,如鱼得水。
|
||||
|
||||
`uView Pro`,是全面支持 Vue3.0、TypeScript 的 uni-app 生态框架,uView Pro 的基线版本是基于 uView 1.8.8 修改,使用 TypeScript 完全重构,目前已全面支持 uni-app Vue3.0。
|
||||
|
||||
## [组件库文档:https://uviewpro.cn](https://uviewpro.cn)
|
||||
|
||||
## [快速启动模板:https://starter.uviewpro.cn](https://starter.uviewpro.cn)
|
||||
|
||||
## 特性
|
||||
|
||||
- 兼容安卓,iOS,微信小程序,H5,QQ 小程序,百度小程序,支付宝小程序,头条小程序
|
||||
- 70+精选组件,功能丰富,多端兼容,让您快速集成,开箱即用
|
||||
- 众多贴心的 JS 利器,让您飞镖在手,召之即来,百步穿杨
|
||||
- 众多的常用页面和布局,让您专注逻辑,事半功倍
|
||||
- 详尽的文档支持,现代化的演示效果
|
||||
- 按需引入,精简打包体积
|
||||
|
||||
## 鸿蒙预览
|
||||
|
||||
🎉🎉 uView Pro 鸿蒙端应用已正式上线华为鸿蒙应用市场,为您提供完整的业务场景演示平台,包含组件库、模板示例、场景案例等,支持一键复制下载,帮助开发者快速上手并体验组件的实际应用价值!
|
||||
|
||||
> 系统要求:仅支持 HarmonyOS 5.0 及以上版本设备
|
||||
|
||||
<table>
|
||||
<tr align="center">
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_harmony.png" width="180" height="180" ></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>鸿蒙应用</strong><br>(浏览器扫码)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 手机预览
|
||||
|
||||
您可以通过**微信**或**手机浏览器**扫描以下二维码,查看最佳的演示效果。
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_uview_pro_wx.jpg" width="150" height="150" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_uview_pro_alipay.png" width="150" height="150" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_uview_pro_h5.png" width="150" height="150" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qr_uview_pro_android.png" width="150" height="150" ></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>微信小程序</strong><br>(微信扫码)</td>
|
||||
<td align="center"><strong>支付宝小程序</strong><br>(支付宝扫码)</td>
|
||||
<td align="center"><strong>H5</strong><br>(浏览器扫码)</td>
|
||||
<td align="center"><strong>Android</strong><br>(浏览器扫码)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
运行示例工程,请[下载源码](https://github.com/anyup/uview-pro)后,在项目根目录执行以下命令:
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
npm install
|
||||
|
||||
npm run dev # H5,默认连本机后端
|
||||
npm run dev:mp-weixin # 微信小程序:产物在 dist/build/mp-weixin,用微信开发者工具导入该目录
|
||||
npm run dev:app # App:产物在 dist/dev/app,再用 HBuilderX 打包运行
|
||||
```
|
||||
|
||||
更多运行和构建命令参考:[pnpm 运行指南](README-pnpm.md)
|
||||
发布构建与检查:
|
||||
|
||||
## 链接
|
||||
| 命令 | 作用 |
|
||||
| --- | --- |
|
||||
| `npm run build` | H5 发布构建 |
|
||||
| `npm run build:mp-weixin` | 微信小程序发布构建 |
|
||||
| `npm run build:app` | App 发布构建 |
|
||||
| `npm run type-check` | vue-tsc 全量类型检查 |
|
||||
| `npm run lint` / `npm run lint:fix` | ESLint 检查 / 自动修复 |
|
||||
|
||||
- [Github](https://github.com/anyup/uview-pro)
|
||||
- [Gitee](https://gitee.com/anyup/uview-pro)
|
||||
- [官方文档](https://uviewpro.cn)
|
||||
- [更新日志](https://github.com/anyup/uView-Pro/blob/master/src/uni_modules/uview-pro/changelog.md)
|
||||
## 后端地址(.env 分层)
|
||||
|
||||
## 交流反馈
|
||||
换环境只改项目根的 `.env` / `.env.development` / `.env.production`,不要在代码里写死地址:
|
||||
|
||||
`uView Pro` 微信交流群: [点击进入](https://uviewpro.cn/zh/resource/about.html)
|
||||
| 变量 | 生效场景 | 缺省回落值 |
|
||||
| --- | --- | --- |
|
||||
| `VITE_WEB_API_ROOT` | 发布构建 | `https://uat.youjiala.com` |
|
||||
| `VITE_WEB_API_ROOT_DEV` | 开发构建(H5) | `http://localhost:5235` |
|
||||
| `VITE_WEB_API_ROOT_DEV_APP` | 开发构建(App 真机/模拟器) | `https://uat.youjiala.com` |
|
||||
|
||||
`uView Pro` QQ 交流群: [点击进入](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=98nSVDldWEbDdq4lxiP4aL7uATfMSlI6&authKey=G2yQJ5MQiKzMldaxBsIfKt17NuJuUw8Fr6zdKLggc6NZXgw4BVbqkU2U3EE994yd&noverify=0&group_code=811732166)
|
||||
变量缺失时由 `src/policy/custom/webSitePolicy.ts` 的常量兜底,删掉 `.env` 也能构建出可用产物。
|
||||
取值口径、新增变量的步骤与三端差异见 [src/md/environment.md](src/md/environment.md)。
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/weixin-chat-cl.png" width="250" height="345" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/qq-chat.png" width="250" height="345" ></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>微信群</strong><br></td>
|
||||
<td align="center"><strong>QQ群</strong><br></td>
|
||||
</tr>
|
||||
</table>
|
||||
## 目录速览
|
||||
|
||||
## 关于 PR
|
||||
|
||||
我们非常乐意接受各位的优质 PR,但在此之前我希望您了解 `uView Pro` 是一个需要兼容多个平台的(小程序、h5、iOS App、Android App)包括 nvue 页面、vue 页面。
|
||||
|
||||
所以希望在您修复 bug 并提交之前尽可能的去这些平台测试一下兼容性。最好能携带测试截图以方便审核。非常感谢!
|
||||
|
||||
## 安装配置
|
||||
|
||||
`uView Pro` 支持 `npm` 和 `uni_modules` 两种主流安装方式,配置方式高度一致。无论采用哪种方式,均可通过 `easycom` 实现组件自动引入,极大提升开发效率。以下为统一的配置说明:
|
||||
|
||||
### 1. 安装 uView Pro
|
||||
|
||||
- npm 安装:
|
||||
|
||||
```bash
|
||||
npm install uview-pro
|
||||
# 或
|
||||
yarn add uview-pro
|
||||
# 或
|
||||
pnpm add uview-pro
|
||||
```
|
||||
src/
|
||||
├── api/ service/ 后端接口封装(service/* 由代码生成器维护)
|
||||
├── common/ 请求拦截器、全局错误边界、样式与常量
|
||||
├── pages/ 页面:login / dashboard / error(page401,403,404)
|
||||
├── permission/ 权限内核与页面权限表
|
||||
├── policy/custom/ 站点配置(.env 的 fallback)
|
||||
├── router/ 路径别名、导航拦截器
|
||||
├── stores/ Pinia
|
||||
├── md/ 工程文档(下表)
|
||||
└── manifest.json 三端应用身份(appid / bundleName)
|
||||
```
|
||||
|
||||
- uni_modules 安装:
|
||||
## 改代码前先读的文档
|
||||
|
||||
通过 HBuilderX 插件市场或手动下载,将 uView Pro 放入 `uni_modules` 目录。
|
||||
| 文档 | 讲什么 |
|
||||
| --- | --- |
|
||||
| [src/md/environment.md](src/md/environment.md) | .env 分层、mode 映射、三端读取差异 |
|
||||
| [src/md/error-handling.md](src/md/error-handling.md) | 全局错误边界与「已提示过就不再打扰」的口径 |
|
||||
| [src/md/permission.md](src/md/permission.md) | 权限模型、页面权限表、菜单可见性 |
|
||||
| [src/md/page-routing.md](src/md/page-routing.md) | 路由别名、导航拦截、错误页跳转链 |
|
||||
|
||||
[插件市场:https://ext.dcloud.net.cn/plugin?id=24633](https://ext.dcloud.net.cn/plugin?id=24633)
|
||||
代码风格由 ESLint 约束,但 IDE 里同时生效的 Prettier 与它存在少量对撞规则(箭头括号、运算符换行位置);
|
||||
冲突写法已在文档里给了规避方式,遇到 `--fix` 反复回退请改写构造而不是硬碰。
|
||||
|
||||
### 2. 引入 uView Pro 主库
|
||||
## 待替换的应用身份
|
||||
|
||||
在 `main.ts` 中引入并注册 uView Pro:
|
||||
`src/manifest.json` 与 `harmony-configs/build-profile.json5` 里的脚手架身份已清成占位,正式发布前需逐项替换:
|
||||
|
||||
```js
|
||||
// npm 方式
|
||||
import uViewPro from 'uview-pro'
|
||||
import { createSSRApp } from 'vue'
|
||||
// uni_modules 方式
|
||||
// import uViewPro from "@/uni_modules/uview-pro";
|
||||
- DCloud `appid`:当前为占位 `__UNI__YJL0001`,需在 HBuilderX 登录自有账号后重新领取
|
||||
- 微信小程序 `appid`:故意留空(构建时回落为 `touristappid`,只能本地预览),发布前换成自有 AppID
|
||||
- 鸿蒙 `bundleName`:当前 `com.youjiala.app`,需与华为侧注册的包名一致
|
||||
- 鸿蒙签名材料路径:`harmony-configs/build-profile.json5` 里指向 `./signing/youjiala.*`,需放入自有证书
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
app.use(uViewPro)
|
||||
return {
|
||||
app
|
||||
}
|
||||
}
|
||||
```
|
||||
## 许可
|
||||
|
||||
### 3. 引入全局样式
|
||||
|
||||
在 `uni.scss` 中引入主题样式:
|
||||
|
||||
```scss
|
||||
/* npm 方式 */
|
||||
@import 'uview-pro/theme.scss';
|
||||
/* uni_modules 方式 */
|
||||
/* @import "@/uni_modules/uview-pro/theme.scss"; */
|
||||
```
|
||||
|
||||
在 `App.vue` 首行引入基础样式:
|
||||
|
||||
```scss
|
||||
<style lang="scss">
|
||||
/* npm 方式 */
|
||||
@import "uview-pro/index.scss";
|
||||
/* uni_modules 方式 */
|
||||
/* @import "@/uni_modules/uview-pro/index.scss"; */
|
||||
</style>
|
||||
```
|
||||
|
||||
### 4. 配置 easycom 自动引入组件
|
||||
|
||||
在 `pages.json` 中配置 `easycom` 规则,实现组件自动引入:
|
||||
|
||||
```json
|
||||
{
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
// npm 方式
|
||||
"^u-(.*)": "uview-pro/components/u-$1/u-$1.vue"
|
||||
// uni_modules 方式
|
||||
// "^u-(.*)": "@/uni_modules/uview-pro/components/u-$1/u-$1.vue"
|
||||
}
|
||||
},
|
||||
"pages": []
|
||||
}
|
||||
```
|
||||
|
||||
**温馨提示**
|
||||
|
||||
- 1.修改 `easycom` 规则后需重启 HX 或重新编译项目。
|
||||
- 2.请确保 `pages.json` 中只有一个 easycom 字段,否则请自行合并多个规则。
|
||||
- 3.一定要放在 `custom` 内,否则无效。
|
||||
|
||||
### 5. Volar 类型提示支持
|
||||
|
||||
如需在 `CLI` 项目中获得 `Volar` 的全局类型提示,请在 `tsconfig.json` 中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
// npm 方式
|
||||
"types": ["uview-pro/types"]
|
||||
// uni_modules 方式
|
||||
// "types": ["@/uni_modules/uview-pro/types"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> HBuilderX 项目暂不支持 `tsconfig.json` 的 `types` 配置,`CLI` 项目推荐配置以获得最佳 `TS` 体验。
|
||||
|
||||
### 6. 组件使用
|
||||
|
||||
配置完成后,无需 `import` 和 `components` 注册,可直接在 `SFC` 中使用 `uView Pro` 组件:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<u-button type="primary">
|
||||
按钮
|
||||
</u-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
请通过[快速上手](https://uviewpro.cn/zh/components/quickstart.html)了解更详细的内容
|
||||
|
||||
## 捐赠 uView Pro
|
||||
|
||||
`uView Pro` 文档内容和框架源码基于 `uView UI` 二次开发,因此全部开源免费,如果您认为 `uView Pro` 帮到了您的开发工作,您可以捐赠 `uView Pro` 的研发工作,捐赠无门槛,哪怕是一杯可乐也好(相信这比打赏主播更有意义)。
|
||||
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/wechat-pay.png" width="250" height="345" ></td>
|
||||
<td><img src="https://ik.imagekit.io/anyup/images/social/ali-pay.png" width="250" height="345" ></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><strong>微信</strong><br></td>
|
||||
<td align="center"><strong>支付宝</strong><br></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 贡献者
|
||||
|
||||
<a href="https://github.com/anyup/uView-Pro/graphs/contributors">
|
||||
<img alt="Contributors" src="https://contrib.rocks/image?repo=anyup/uView-Pro" />
|
||||
</a>
|
||||
|
||||
## 版权信息
|
||||
|
||||
`uView Pro` 遵循[MIT](https://en.wikipedia.org/wiki/MIT_License)开源协议,意味着您无需支付任何费用,也无需授权,即可将 `uView Pro` 应用到您的产品中。
|
||||
|
||||
## 鸣谢
|
||||
|
||||
再次感谢 `uView UI` 开发团队,以及所有为 `uView UI` 的贡献者,以及所有为 `uView Pro` 的贡献者。
|
||||
|
||||
- [Github](https://github.com/anyup/uview-pro)
|
||||
- [Gitee](https://gitee.com/anyup/uview-pro)
|
||||
- [uView UI 1.0](https://github.com/umicro/uView)
|
||||
- [uView UI 2.0](https://github.com/umicro/uView2.0)
|
||||
界面组件库 uview-pro 与其脚手架遵循 MIT(见 `LICENSE`);业务代码版权归本项目所有。
|
||||
@@ -5,12 +5,12 @@
|
||||
name: 'default',
|
||||
type: 'HarmonyOS',
|
||||
material: {
|
||||
certpath: 'D:/workspace/anyup/uView-Pro-Starter/docs/uviewpro.cer',
|
||||
keyAlias: 'uviewpro',
|
||||
certpath: './signing/youjiala.cer',
|
||||
keyAlias: 'youjiala',
|
||||
keyPassword: '',
|
||||
profile: 'D:/workspace/anyup/uView-Pro-Starter/docs/uviewpro_prodRelease.p7b',
|
||||
profile: './signing/youjiala_prodRelease.p7b',
|
||||
signAlg: 'SHA256withECDSA',
|
||||
storeFile: 'D:/workspace/anyup/uView-Pro-Starter/docs/uviewpro.p12',
|
||||
storeFile: './signing/youjiala.p12',
|
||||
storePassword: ''
|
||||
}
|
||||
},
|
||||
@@ -18,13 +18,13 @@
|
||||
name: 'release',
|
||||
type: 'HarmonyOS',
|
||||
material: {
|
||||
storeFile: 'D:/workspace/anyup/uView-Pro-Starter/docs/uviewpro.p12',
|
||||
storeFile: './signing/youjiala.p12',
|
||||
storePassword: '',
|
||||
keyAlias: 'uviewpro',
|
||||
keyAlias: 'youjiala',
|
||||
keyPassword: '',
|
||||
signAlg: 'SHA256withECDSA',
|
||||
profile: 'D:/workspace/anyup/uView-Pro-Starter/docs/uviewpro_prodRelease.p7b',
|
||||
certpath: 'D:/workspace/anyup/uView-Pro-Starter/docs/uviewpro.cer'
|
||||
profile: './signing/youjiala_prodRelease.p7b',
|
||||
certpath: './signing/youjiala.cer'
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.34.5",
|
||||
"overrides": {
|
||||
"unconfig": "7.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"pnpm": ">=10.0.0"
|
||||
"npm": ">=9.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "uni",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
allowBuilds:
|
||||
'@uni-helper/unocss-preset-uni': true
|
||||
bufferutil: true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
overrides:
|
||||
unconfig: 7.3.2
|
||||
@@ -1,25 +1,9 @@
|
||||
<!-- eslint-disable no-console -->
|
||||
<script setup lang="ts">
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { useLocale, useTheme } from "uview-pro";
|
||||
import { onMounted } from "vue";
|
||||
|
||||
const { darkMode, currentTheme } = useTheme();
|
||||
const { currentLocale, locales } = useLocale();
|
||||
|
||||
onLoad(() => {
|
||||
console.log("App.root.vue onLoad");
|
||||
console.log("darkMode->", darkMode.value);
|
||||
console.log("theme->", currentTheme.value?.name);
|
||||
console.log("locale->", currentLocale.value?.name);
|
||||
console.log("locales->", locales.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
console.log("App.root.vue onMounted");
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
根组件(由 vite.config.ts 的 UniRoot 插件包在所有页面外层)
|
||||
只承担两件事:给 uview-pro 提供主题/多语言上下文,注册全局 u-toast。
|
||||
脚手架原有的 onLoad/onMounted 调试打印已移除:主题与语言的实际生效链路在
|
||||
main.ts(i18n + setTheme)与 theme.json 里,这里重复读取只会污染真机日志。
|
||||
-->
|
||||
<template>
|
||||
<u-config-provider>
|
||||
<slot />
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch } from "@dcloudio/uni-app";
|
||||
import { setupGlobalErrorBoundary } from "@/common/global-error";
|
||||
import { setupNavGuard } from "@/router/nav-guard";
|
||||
|
||||
// 全局错误边界:必须在 setup 顶层注册(uni 的 App 级钩子要绑到 App 实例上),
|
||||
// 内部同时挂 uni 钩子与 H5 的 window 兜底,原因见 src/md/error-handling.md §2
|
||||
setupGlobalErrorBoundary();
|
||||
|
||||
// 启动即挂导航拦截器:首次站内跳转就被管住(冷启动深链由页面级 useAuthGuard 复核)
|
||||
onLaunch(() => {
|
||||
setupNavGuard();
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 全局错误边界:兜住"没人处理的运行时错误与 Promise 拒绝"
|
||||
*
|
||||
* 为什么必须有:uni-app 没有 vue-router 的 errorHandler,页面里一个未 catch 的
|
||||
* promise 只会在控制台留一行日志,用户看到的是"点了没反应"或白屏。
|
||||
* 本模块负责把这类异常变成:控制台一定有记录 + 用户按需得到一次轻提示。
|
||||
*
|
||||
* 三条纪律(详见 src/md/error-handling.md):
|
||||
* 1) 请求层已经提示过的错误不再打扰用户——用 markErrorNotified 打标记,这里认标记;
|
||||
* 2) 同一文案节流去重,避免循环里报错把屏幕刷满弹窗;
|
||||
* 3) 只记录与提示,绝不吞掉错误:该抛的仍然抛,防止"边界把故障藏起来"。
|
||||
*
|
||||
* 平台差异(2026-10-13 用 H5 真跑一遍测出来的,不是抄文档):
|
||||
* uni-h5 的 ON_ERROR 只在 Vue 的 app.config.errorHandler 里派发,组件同步错误才走得到
|
||||
* App.onError;setTimeout / 事件回调里的异常够不着它,而 onUnhandledRejection 在 uni-h5
|
||||
* 全文没有任何派发点(搜 unhandledrejection 命中 0)。所以 H5 必须自己挂 window 监听兜底,
|
||||
* 否则"全局错误边界"只是看着挂了、实际从不触发。口径见 src/md/error-handling.md。
|
||||
*/
|
||||
import { onError, onUnhandledRejection } from "@dcloudio/uni-app";
|
||||
|
||||
/** 已在别处提示过的标记键:请求层弹过 toast/modal 或已跳错误页时置位 */
|
||||
const NOTIFIED_MARK = "__youjialaErrorNotified";
|
||||
|
||||
/** 同类文案的重复提示间隔(毫秒) */
|
||||
const THROTTLE_MS = 3000;
|
||||
|
||||
/** 记录表容量上限,超过即清理过期项,防止长时间运行后无限增长 */
|
||||
const TRACK_MAX = 64;
|
||||
|
||||
/** 上一次提示的时间戳,按文案分桶 */
|
||||
const lastShownAt = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* 标记"这个错误已经提示过用户",全局边界收到它就只记日志。
|
||||
* 供请求层调用:它已经弹过 modal / 已经跳了 401、403 页,再提示一次就是打扰。
|
||||
* 原样返回入参,方便在 `throw markErrorNotified(err)` / `reject(markErrorNotified(err))` 处内联。
|
||||
*/
|
||||
export function markErrorNotified<T>(error: T): T {
|
||||
if (error && (typeof error === "object" || typeof error === "function")) {
|
||||
(error as Record<string, unknown>)[NOTIFIED_MARK] = true;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
/** 是否已被别处提示过 */
|
||||
function isNotified(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
return (error as Record<string, unknown>)[NOTIFIED_MARK] === true;
|
||||
}
|
||||
|
||||
/** 从错误对象里依次尝试取值可读文案的字段名 */
|
||||
const MESSAGE_KEYS = [
|
||||
"message",
|
||||
"errMsg",
|
||||
"errorMessage",
|
||||
"msg",
|
||||
"Message",
|
||||
// 兜底:万一有调用方把整个事件对象丢进来(window / uni 的拒绝事件都带 reason)
|
||||
"reason",
|
||||
] as const;
|
||||
|
||||
/** 从任意形态的错误里抠出一行可读文案(Error / 字符串 / uni 回调 / 后端包装) */
|
||||
function describe(error: unknown, depth = 0): string {
|
||||
if (!error) {
|
||||
return "";
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
// 非对象、或已经在往里挖第二层:直接字符串化(Error 会得到 "Error: 文案")
|
||||
if (typeof error !== "object" || depth > 1) {
|
||||
return String(error);
|
||||
}
|
||||
const raw = error as Record<string, unknown>;
|
||||
for (const key of MESSAGE_KEYS) {
|
||||
const value = raw[key];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
// 字段自己是个错误对象(window / uni 的 reason 常是 Error):只再挖一层,防止成环
|
||||
if (depth === 0 && value && typeof value === "object") {
|
||||
const nested = describe(value, 1);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/** 同一文案在 THROTTLE_MS 内只允许提示一次 */
|
||||
function shouldNotify(text: string): boolean {
|
||||
const now = Date.now();
|
||||
const previous = lastShownAt.get(text) ?? 0;
|
||||
if (now - previous < THROTTLE_MS) {
|
||||
return false;
|
||||
}
|
||||
if (lastShownAt.size >= TRACK_MAX) {
|
||||
lastShownAt.forEach((stamped, key) => {
|
||||
if (now - stamped > THROTTLE_MS * 10) {
|
||||
lastShownAt.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
lastShownAt.set(text, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 错误来源:运行时异常 / 未处理的 Promise 拒绝 */
|
||||
export type GlobalErrorSource = "error" | "rejection";
|
||||
|
||||
/** 开发期弹窗最长占用时间:过了这个点就允许下一条弹窗顶上 */
|
||||
const MODAL_MAX_HOLD_MS = 5000;
|
||||
|
||||
/** 下一条开发期弹窗可以出现的最早时刻(Date.now() 基准) */
|
||||
let modalFreeAt = 0;
|
||||
|
||||
/**
|
||||
* 开发期弹窗:uni 的 showModal 在 H5 是单例,后一次调用会直接改写前一次的标题与正文,
|
||||
* 连着来两条错误时用户只会看到最后一条。这里按时间槽排队,让每条都被看得见;
|
||||
* 到点没确认也放行(最多晚 MODAL_MAX_HOLD_MS 秒),不让弹窗把后续错误堵死。
|
||||
*/
|
||||
function showDevModal(title: string, content: string): void {
|
||||
const now = Date.now();
|
||||
const wait = Math.max(0, modalFreeAt - now);
|
||||
modalFreeAt = now + wait + MODAL_MAX_HOLD_MS;
|
||||
setTimeout(() => {
|
||||
uni.showModal({
|
||||
title,
|
||||
content,
|
||||
showCancel: false,
|
||||
});
|
||||
}, wait);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一入口:记日志 -> 判断要不要提示。
|
||||
* source 只用于日志前缀与开发期弹窗标题,生产提示里不带它:用户不需要内部术语。
|
||||
*/
|
||||
export function reportGlobalError(
|
||||
error: unknown,
|
||||
source: GlobalErrorSource,
|
||||
): void {
|
||||
const text = describe(error);
|
||||
// 全局边界的职责就是留痕:console.error 在 no-console 配置里本就放行,无需 disable 注释
|
||||
console.error(`[global:${source}]`, error);
|
||||
if (isNotified(error)) {
|
||||
return;
|
||||
}
|
||||
if (!text || !shouldNotify(text)) {
|
||||
return;
|
||||
}
|
||||
// 开发期把详情摊开:这时"看到了但没提示"和"确实没报错"必须能一眼区分
|
||||
if (import.meta.env.DEV) {
|
||||
showDevModal(`未捕获错误(${source})`, text.slice(0, 300));
|
||||
return;
|
||||
}
|
||||
// 生产期只给一次轻提示:详情已进 console,用户不需要看堆栈
|
||||
uni.showToast({
|
||||
title: "操作未成功,请稍后重试",
|
||||
icon: "none",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
/** window 兜底只挂一次:H5 的 HMR / 多次挂载不该叠出多份监听 */
|
||||
let windowFallbackInstalled = false;
|
||||
|
||||
/** 只有浏览器环境才有 window:小程序与 App 的逻辑层拿不到,返回 undefined 即跳过 */
|
||||
function browserWindow(): Window | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
return typeof window.addEventListener === "function" ? window : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 挂载全局错误边界,在 App.vue 的 setup 顶层调用一次(钩子必须注册在 App 实例上,
|
||||
* 放进 onLaunch 回调里就离开了 setup 上下文)。
|
||||
*
|
||||
* 为什么要额外挂 window:uni-h5 的 ON_ERROR 只由 Vue 的 errorHandler 触发,
|
||||
* onUnhandledRejection 在 uni-h5 里根本没有派发点(实测:搜 unhandledrejection 命中 0),
|
||||
* 所以 setTimeout / 事件回调里的异常与所有 Promise 拒绝在 H5 上都到不了 uni 钩子,
|
||||
* 必须由 window 兜底;小程序 / App 端由各自运行时派发 uni 钩子,这段自动跳过。
|
||||
*/
|
||||
export function setupGlobalErrorBoundary(): void {
|
||||
onError((error) => {
|
||||
reportGlobalError(error, "error");
|
||||
});
|
||||
onUnhandledRejection((event) => {
|
||||
reportGlobalError(event?.reason, "rejection");
|
||||
});
|
||||
|
||||
const target = browserWindow();
|
||||
if (!target || windowFallbackInstalled) {
|
||||
return;
|
||||
}
|
||||
windowFallbackInstalled = true;
|
||||
target.addEventListener("error", (event) => {
|
||||
// 资源加载失败(img/script 404)时 error 为 null,只有 message:照常留痕
|
||||
reportGlobalError(event.error ?? event.message, "error");
|
||||
});
|
||||
target.addEventListener("unhandledrejection", (event) => {
|
||||
reportGlobalError(event.reason, "rejection");
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "uview-pro";
|
||||
import type { JwtInfo } from "@/data/auto/jwtInfo";
|
||||
import { http } from "uview-pro";
|
||||
import { markErrorNotified } from "@/common/global-error";
|
||||
import { toBackendLocale } from "@/common/locale-const";
|
||||
import { WebSitePolicy } from "@/policy/custom/webSitePolicy";
|
||||
import {
|
||||
@@ -28,10 +29,11 @@ import {
|
||||
|
||||
// ==================== 全局配置 ====================
|
||||
|
||||
// 后端地址唯一来源:policy/custom/webSitePolicy(代码生成器维护,改地址只动那里)
|
||||
// 后端地址唯一来源:policy/custom/webSitePolicy(值可被项目根 .env 覆盖,缺失时回落该文件常量)
|
||||
// 与旧系统 WebVue 同逻辑:开发构建用 WebApiRootDev,发布构建用 WebApiRoot
|
||||
// App 端开发构建(真机/模拟器)连不上宿主机的 localhost,改用 WebApiRootDevApp
|
||||
// 小程序端需在平台配置 https 合法域名;本地 H5 联调如遇 CORS 需后端放行开发端口
|
||||
// 换环境只改 .env(详见 src/md/environment.md),本文件不允许再写死地址
|
||||
let devBaseUrl = WebSitePolicy.WebApiRootDev;
|
||||
// #ifdef APP-PLUS
|
||||
devBaseUrl = WebSitePolicy.WebApiRootDevApp;
|
||||
@@ -187,6 +189,18 @@ function notifyError(detail: string, fallback = "请求失败") {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 抛出请求层错误
|
||||
* @param message 供调用方与日志使用的错误文案
|
||||
* @param handled 为 true 表示这一处已经提示过用户(toast / modal / 跳错误页),
|
||||
* 打上标记后全局边界只记日志、不再重复打扰;false 时原样抛出,
|
||||
* 把兜底提示留给调用方(自己 catch 后处理)或全局边界
|
||||
*/
|
||||
function throwRequestError(message: string, handled: boolean): never {
|
||||
const err = new Error(message);
|
||||
throw handled ? markErrorNotified(err) : err;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务异常识别:HTTP 成功但 body 是 .NET 异常对象时命中
|
||||
* @returns 命中返回异常消息(调用方需抛出),未命中返回空串
|
||||
@@ -261,11 +275,11 @@ const httpInterceptor: RequestInterceptor = {
|
||||
// 网络层错误
|
||||
if (errMsg && errMsg.includes("Failed to connect")) {
|
||||
meta.toast && notifyError("", "网络错误");
|
||||
throw new Error("网络错误");
|
||||
throwRequestError("网络错误", !!meta.toast);
|
||||
}
|
||||
if (errMsg && errMsg.includes("request:fail")) {
|
||||
meta.toast && notifyError("", "请求错误:未知");
|
||||
throw new Error("请求错误:未知");
|
||||
throwRequestError("请求错误:未知", !!meta.toast);
|
||||
}
|
||||
|
||||
// 401 且未重试过:共用刷新 + 重试
|
||||
@@ -273,7 +287,8 @@ const httpInterceptor: RequestInterceptor = {
|
||||
if (!getToken()) {
|
||||
// 本来就没有登录态,直接去登录页
|
||||
goLogin(true);
|
||||
throw new Error("登录已失效,请重新登录");
|
||||
// 页面已经跳到登录页,算已告知用户,边界不再弹提示
|
||||
throwRequestError("登录已失效,请重新登录", true);
|
||||
}
|
||||
// _retry 标记:重试请求再 401 时落入下方非 2xx 分支,防止无限递归
|
||||
config._retry = true;
|
||||
@@ -284,7 +299,8 @@ const httpInterceptor: RequestInterceptor = {
|
||||
}
|
||||
catch (refreshError: any) {
|
||||
goLogin(!!(refreshError && refreshError.status));
|
||||
throw refreshError;
|
||||
// 已经跳回登录页,保留原错误对象(status 供调用方分支)只补标记
|
||||
throw markErrorNotified(refreshError);
|
||||
}
|
||||
// ② 重试放在 try 之外:它自身的失败(500/403 等)只算这次业务请求失败,
|
||||
// 不能当成「刷新失败」,否则会误清已成功续期用户的登录态
|
||||
@@ -298,25 +314,30 @@ const httpInterceptor: RequestInterceptor = {
|
||||
// 请求错误(非 2xx)
|
||||
if (!(statusCode >= 200 && statusCode < 300)) {
|
||||
// 403:权限不足,跳 403 页列出原因(401 走上面的续期 / 回登录页链路,不进错误页)
|
||||
let handled = false;
|
||||
if (statusCode === 403) {
|
||||
goForbidden();
|
||||
handled = true; // 已跳 403 页并列出原因,边界不再重复提示
|
||||
}
|
||||
// 异常体优先:.NET 后端常以 500 + JSON 异常返回业务错误
|
||||
const bizMsg = detectBusinessException(rawData);
|
||||
if (bizMsg)
|
||||
throw new Error(bizMsg);
|
||||
throw markErrorNotified(new Error(bizMsg)); // detectBusinessException 内部已提示
|
||||
const errorMessage = `请求错误[${statusCode}]`;
|
||||
meta.toast && notifyError(extractErrorMessage(rawData), errorMessage);
|
||||
const err: any = new Error(`${errorMessage}:${errMsg ?? ""}`);
|
||||
err.status = statusCode; // 结构化状态码,供调用方按 401/403/500 分支处理
|
||||
err.data = rawData;
|
||||
if (handled || meta.toast) {
|
||||
markErrorNotified(err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 2xx 但 body 是业务异常
|
||||
const bizMsg = detectBusinessException(rawData);
|
||||
if (bizMsg)
|
||||
throw new Error(bizMsg);
|
||||
throw markErrorNotified(new Error(bizMsg));
|
||||
|
||||
// 成功:剥壳返回 JSON body(service/* 经 utils/request 直接拿到后端实体对象)
|
||||
return rawData;
|
||||
@@ -351,8 +372,9 @@ export function uploadFile(options: UploadOptions): Promise<any> {
|
||||
header: token ? { Authorization: token, ...header } : header,
|
||||
success: (res) => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
notifyError("", `上传失败[${res.statusCode}]`);
|
||||
reject(new Error(`上传失败[${res.statusCode}]`));
|
||||
const message = `上传失败[${res.statusCode}]`;
|
||||
notifyError("", message);
|
||||
reject(markErrorNotified(new Error(message)));
|
||||
return;
|
||||
}
|
||||
let data: any = res.data;
|
||||
@@ -364,12 +386,12 @@ export function uploadFile(options: UploadOptions): Promise<any> {
|
||||
}
|
||||
const bizMsg = detectBusinessException(data);
|
||||
if (bizMsg)
|
||||
reject(new Error(bizMsg));
|
||||
reject(markErrorNotified(new Error(bizMsg)));
|
||||
else resolve(data);
|
||||
},
|
||||
fail: (err: any) => {
|
||||
notifyError(err.errMsg ?? "", "上传失败");
|
||||
reject(err);
|
||||
reject(markErrorNotified(err));
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -460,11 +482,12 @@ export async function downloadFile(options: DownloadOptions): Promise<void> {
|
||||
/* 解析失败沿用默认文案 */
|
||||
}
|
||||
notifyError(msg, "下载失败");
|
||||
throw new Error(msg);
|
||||
throw markErrorNotified(new Error(msg));
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
notifyError("", `下载失败[${res.statusCode}]`);
|
||||
throw new Error(`下载失败[${res.statusCode}]`);
|
||||
const message = `下载失败[${res.statusCode}]`;
|
||||
notifyError("", message);
|
||||
throw markErrorNotified(new Error(message));
|
||||
}
|
||||
|
||||
const fileName = `${filename}${timestamp()}.${extension}`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "uView Pro Starter",
|
||||
"appid": "__UNI__8BA4DF3",
|
||||
"description": "uView Pro Starter",
|
||||
"name": "有家相册",
|
||||
"appid": "__UNI__YJL0001",
|
||||
"description": "有家相册:家庭相册、家族树与文档共享的跨端应用",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "1",
|
||||
"transformPx": false,
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "wxbb176981bffc17fe",
|
||||
"appid": "",
|
||||
"setting": {
|
||||
"urlCheck": false
|
||||
},
|
||||
@@ -89,7 +89,7 @@
|
||||
}
|
||||
},
|
||||
"distribute": {
|
||||
"bundleName": "cn.anyup.uviewpro",
|
||||
"bundleName": "com.youjiala.app",
|
||||
"icons": {
|
||||
"foreground": "unpackage/res/icons/1024x1024.png",
|
||||
"background": "unpackage/res/icons/1024x1024.png"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# 环境变量分层(.env 与 WebSitePolicy 的分工)
|
||||
|
||||
适用范围:项目根 `.env` / `.env.development` / `.env.production`、`src/types/env.d.ts`、`src/policy/custom/webSitePolicy.ts`、`src/common/http.interceptor.ts` 的地址取值。
|
||||
本文记录"为什么这样分层""三端读取为什么必须写字面量",以及新增一个变量的固定步骤。错误处理口径见 [error-handling.md](./error-handling.md),路由与权限见 [page-routing.md](./page-routing.md)、[permission.md](./permission.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 分层模型:谁覆盖谁
|
||||
|
||||
`vite.config.ts` 没有设置 `envDir` / `envPrefix`,所以走 Vite 默认:
|
||||
|
||||
- **加载目录 = 项目根**(`uniapp/youjiala/`,即 `index.html` 所在层),**不是 `src/`**;
|
||||
- **只有 `VITE_` 前缀的键**会注入 `import.meta.env`,其它键不会(也不该)出现在前端产物里。
|
||||
|
||||
| 文件 | 何时生效 | 是否提交 |
|
||||
| --- | --- | --- |
|
||||
| `.env` | 所有 mode 的基底 | 提交 |
|
||||
| `.env.development` | `uni` / `uni -p xxx`(mode=development) | 提交 |
|
||||
| `.env.production` | `uni build` / `uni build -p xxx`(mode=production) | 提交 |
|
||||
| `.env.local`、`.env.development.local`、`.env.production.local` | 同名 mode 下优先级最高 | **不提交**(`.gitignore` 里 `*.local` 已覆盖) |
|
||||
|
||||
mode 由 uni 命令决定(要指定别的 mode 得显式加 `--mode`):
|
||||
|
||||
```
|
||||
uni / uni -p mp-weixin / uni -p app => development => .env + .env.development(+.local)
|
||||
uni build / uni build -p xxx => production => .env + .env.production(+.local)
|
||||
```
|
||||
|
||||
同名项后者覆盖前者;`.env.development` / `.env.production` 里**只列与 `.env` 不同的项**,重复抄一遍只会让三处值互相打架。
|
||||
|
||||
## 2. 只放"换环境要动"的东西
|
||||
|
||||
当前只有三个键,全部是后端根地址:
|
||||
|
||||
| 键 | 生效场景 | 缺省回落值 |
|
||||
| --- | --- | --- |
|
||||
| `VITE_WEB_API_ROOT` | 发布构建(三端共用) | `https://uat.youjiala.com` |
|
||||
| `VITE_WEB_API_ROOT_DEV` | 开发构建,H5 | `http://localhost:5235` |
|
||||
| `VITE_WEB_API_ROOT_DEV_APP` | 开发构建,App 真机/模拟器 | `https://uat.youjiala.com` |
|
||||
|
||||
分工纪律:
|
||||
|
||||
- **品牌与文案**(`Title` / `Copy` / `Beian`)、**路由常量**(`Dashboard` / `Web401~404`)、**时间格式**留在 `webSitePolicy.ts` 的常量里,不进 `.env`:它们跟着版本走,不跟着环境走。
|
||||
- **密钥、token、数据库口令一律禁止进 `.env`**。前端产物人人可读,`.env` 又被提交,任何写进来的凭据等同于公开发布。
|
||||
- 本机差异(自己的后端端口、局域网 IP)写 `.env.*.local`,不要提交本机地址。
|
||||
|
||||
## 3. fallback:删掉 .env 也必须能构建
|
||||
|
||||
`webSitePolicy.ts` 里每个地址都写成"env 优先、常量兜底":
|
||||
|
||||
```ts
|
||||
/** 取环境变量:非字符串、空串、纯空白都算未配置,回落 fallback */
|
||||
function env(value: string | undefined, fallback: string): string {
|
||||
const text = String(value ?? "").trim();
|
||||
return text.length > 0 ? text : fallback;
|
||||
}
|
||||
|
||||
export class WebSitePolicy {
|
||||
static readonly WebApiRootDev: string = env(
|
||||
import.meta.env.VITE_WEB_API_ROOT_DEV,
|
||||
"http://localhost:5235",
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
所以:
|
||||
|
||||
- `.env` 缺失、键漏写、值写成空串或只有空格 —— 都落到文件里的常量,构建不报错、产物可运行;
|
||||
- 文件里的常量是**最后一道防线**,不是摆设,改动它等于改默认环境,需要同期更新本文与 README 的表格。
|
||||
|
||||
`http.interceptor.ts` 按端挑选常量(`#ifdef APP-PLUS` 是 uni 的条件编译,只有 App 构建会把那行编进去):
|
||||
|
||||
```ts
|
||||
let devBaseUrl = WebSitePolicy.WebApiRootDev;
|
||||
// #ifdef APP-PLUS
|
||||
devBaseUrl = WebSitePolicy.WebApiRootDevApp;
|
||||
// #endif
|
||||
export const baseUrl = import.meta.env.DEV ? devBaseUrl : WebSitePolicy.WebApiRoot;
|
||||
```
|
||||
|
||||
## 4. 硬规矩:只能用字面量读
|
||||
|
||||
```text
|
||||
import.meta.env.VITE_WEB_API_ROOT; // ✅ 三端都正常
|
||||
import.meta.env[key]; // ❌ 小程序 / App 真机上拿到 undefined
|
||||
```
|
||||
|
||||
H5 走浏览器 ESM,`import.meta.env` 是个真实对象,动态取键也能 work;小程序与 App 端由 uni 编译器在构建期把 `import.meta.env.VITE_XXX` **静态替换**成字符串字面量,动态键没人替换,运行时对象根本不存在。
|
||||
|
||||
`src/types/env.d.ts` 显式声明这三个键(`vite/client` 自带的 `ImportMetaEnv` 有 `[key: string]: any` 索引签名,不声明时拼错键名是 `any`、编译器一声不响)。**新增变量必须同时补这份声明**,否则失去拼写保护。
|
||||
|
||||
## 5. 新增一个环境变量的固定四步
|
||||
|
||||
1. 项目根 `.env` 加 `VITE_XXX=默认值`,只在需要按 mode 区分的文件里写覆盖;
|
||||
2. `src/types/env.d.ts` 的 `ImportMetaEnv` 补一行 `readonly VITE_XXX: string;`;
|
||||
3. 消费点写成字面量 `import.meta.env.VITE_XXX`;若是地址/配置类,走 `webSitePolicy` 的 `env()` 包裹以保留 fallback,业务代码继续只读 `WebSitePolicy.*`;
|
||||
4. 更新本文 §2 的表格与 README 的"后端地址"一节。
|
||||
|
||||
## 6. 实测结论(哨兵法)
|
||||
|
||||
改动 `.env` 后不能只看代码,要验证值真的进了产物。做法:把 `.env.production` 的 `VITE_WEB_API_ROOT` 临时改成一个必然不可能出现在源码里的域名(如 `https://env-probe.invalid`),跑 `npm run build` 与 `npm run build:mp-weixin`,然后在产物里搜这个域名。
|
||||
|
||||
本轮结果(2026-10-13):
|
||||
|
||||
| 端 | 产物命中哨兵 | 残留 `import.meta` | 结论 |
|
||||
| --- | --- | --- | --- |
|
||||
| H5 | 是 | 否 | env 生效 |
|
||||
| mp-weixin | 是(`dist/build/mp-weixin/policy/custom/webSitePolicy.js`) | 否 | 编译器已静态替换,小程序同样吃 `.env` |
|
||||
|
||||
验证完必须把 `.env.production` 恢复原值。搜索小程序产物要用递归方式:`Get-ChildItem -Recurse | Select-String`,`Select-String -Path 'dist\**\*.js'` 这种通配在 PowerShell 里不可靠,会给出假的"0 命中"。
|
||||
|
||||
## 7. 常见坑
|
||||
|
||||
- **改了 `.env` 不生效**:Vite 不会热更新 env 文件,必须重启 `uni` / `uni -p` 进程。
|
||||
- **小程序请求发不出去**:平台侧要求 `https` 且域名需在小程序后台"request 合法域名"里登记;本地联调用 `urlCheck: false`(`manifest.json` 的 `mp-weixin.setting`)只是让开发者工具不拦,正式包仍受平台约束。
|
||||
- **H5 连本地后端报 CORS**:是后端未放行开发端口,不是 `.env` 的问题;改 `VITE_WEB_API_ROOT_DEV` 换到已放行的地址即可。
|
||||
- **App 真机连不上**:真机/模拟器上没有宿主机的 `localhost`,走 `VITE_WEB_API_ROOT_DEV_APP`(默认已指向 UAT),需要连本机后端时把它改成局域网 IP。
|
||||
- **别在 `.env` 里写注释续行**:dotenv 只认整行 `#` 注释,一行末尾换行再接裸文本会被当成键值行解析。
|
||||
@@ -0,0 +1,134 @@
|
||||
# 全局错误边界(未捕获异常与 Promise 拒绝的兜底)
|
||||
|
||||
适用范围:`src/common/global-error.ts`、`src/App.vue` 的 `setupGlobalErrorBoundary()` 调用、`src/common/http.interceptor.ts` 的"已提示"标记。
|
||||
本文规定"什么错误由谁提示用户",避免同一个故障弹两遍、或者干脆一声不响。请求层本身的链路见 [page-routing.md](./page-routing.md),地址取值见 [environment.md](./environment.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么必须有
|
||||
|
||||
uni-app 没有 vue-router 那种 `router.onError`,也没有全局 `app.config.errorHandler` 的成熟落点:
|
||||
|
||||
- 页面里一个没 `catch` 的 promise,只会在控制台留一行,用户看到的是"点了没反应";
|
||||
- 运行时抛错若发生在异步回调里,可能连错误页都进不去。
|
||||
|
||||
所以本项目的兜底是 **App 级两个钩子 + 一个上报模块**:保证"控制台一定有记录,用户按需得到一次轻提示"。
|
||||
|
||||
## 2. 数据流与职责
|
||||
|
||||
```text
|
||||
页面 / service 漏 catch
|
||||
-> 错误来源(按端不同,见 §2.1)
|
||||
uni App 钩子:onError(err) / onUnhandledRejection({ reason, promise })
|
||||
H5 额外兜底:window "error" / "unhandledrejection" 监听
|
||||
-> setupGlobalErrorBoundary()(src/common/global-error.ts,在 App.vue setup 顶层调用一次)
|
||||
-> reportGlobalError(error, source)
|
||||
1) console.error 记全量原始错误(永不省略)
|
||||
2) 带"已提示"标记 -> 到此为止,只留日志
|
||||
3) 文案在 3s 内已提示过 -> 静默(去重)
|
||||
4) DEV -> 排队弹 uni.showModal,显示来源与详情前 300 字
|
||||
PROD -> uni.showToast 一句通用文案
|
||||
```
|
||||
|
||||
| 位置 | 负责什么 |
|
||||
| --- | --- |
|
||||
| `App.vue` | 只在 setup 顶层调一次 `setupGlobalErrorBoundary()`,不写判断逻辑 |
|
||||
| `common/global-error.ts` | 钩子注册、H5 window 兜底、文案提取、去重、弹窗排队 |
|
||||
| `common/http.interceptor.ts` | 决定"这一处是否已经打扰过用户",并给错误打标记 |
|
||||
| 页面 / 业务代码 | 能处理就自己 `catch` 并处理;处理不了就别 `catch`,交给边界 |
|
||||
|
||||
钩子注册必须放在 **App.vue 的 setup 顶层**:`onError` / `onUnhandledRejection` 要把回调绑到 App 实例上,放进 `onLaunch` 的回调里就离开了 setup 上下文。
|
||||
|
||||
`onUnhandledRejection` 只把 `event.reason` 传下去(不是整个 event):类型里 `reason` 声明为 `string`,真机常常是 `Error` 对象,传原物才能让 `describe()` 拿到 `message`;`describe()` 也支持再往里挖一层(认 `reason` 字段),所以哪怕误传整个事件对象也不会掉进 `[object Object]`。
|
||||
|
||||
### 2.1 平台派发差异(H5 实测,不是抄文档)
|
||||
|
||||
| 端 | 谁派发 uni 钩子 | 够不着的错误 | 本项目的处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| H5 | `onError` **只由 Vue 的 `app.config.errorHandler` 触发**(组件渲染 / 生命周期里的同步错误);`onUnhandledRejection` 在 `uni-h5` 全文没有派发点(搜 `unhandledrejection` 命中 0) | `setTimeout` / 事件回调里抛出的异常、所有 Promise 拒绝 | 额外挂 `window` 的 `error` / `unhandledrejection` 监听 |
|
||||
| 微信小程序 | 由小程序运行时派发 `App.onError` / `App.onUnhandledRejection`(**本轮未在真机/开发者工具实测**) | 需 capture 阶段才收得到的资源加载错误(未接) | uni 钩子覆盖主链路,§8 列了待办 |
|
||||
| App | 由 uni-app 运行时派发(**同样未实测**) | 同左 | uni 钩子覆盖主链路 |
|
||||
|
||||
第一轮实测的教训就在这里:只在 `App.vue` 里注册 uni 钩子时,浏览器控制台能看到裸的 `边界探针-error`,但 `[global:*]` 一条都没出现、弹窗一次没弹 —— **钩子注册成功≠会被触发**。所以 H5 的 window 兜底是必需项,`setupGlobalErrorBoundary()` 用 `typeof window` 判断只在浏览器里挂,小程序 / App 端自动跳过,`windowFallbackInstalled` 保证 HMR 重复执行也不会叠监听。
|
||||
|
||||
## 3. 三条纪律
|
||||
|
||||
1. **已提示过的不再打扰。** 请求层已经弹过 toast/modal、或已经跳到登录页/403 页的错误,用 `markErrorNotified(err)` 打标记,边界见到标记只写日志。
|
||||
2. **同一文案 3 秒内只提示一次。** 去重按文案分桶(`lastShownAt: Map<string, number>`),记录表上限 64 条,超限时清掉超过 30 秒的旧项,长时间运行不会无限增长。
|
||||
3. **只记录与提示,绝不吞错误。** 该 `throw` 的照旧 `throw`,边界不负责"让程序继续跑"。否则故障会被藏进日志,排查时连"到底报没报"都区分不出来。
|
||||
|
||||
`markErrorNotified` 原样返回入参,所以能内联使用:
|
||||
|
||||
```ts
|
||||
import { markErrorNotified } from "@/common/global-error";
|
||||
|
||||
throw markErrorNotified(new Error(bizMsg));
|
||||
reject(markErrorNotified(err));
|
||||
```
|
||||
|
||||
## 4. 请求层哪些站点打了标记
|
||||
|
||||
`describe()` 依次尝试 `message` / `errMsg` / `errorMessage` / `msg` / `Message` 取值,取不到就 `String(error)` 兜底,因此字符串型错误也能出文案。
|
||||
|
||||
| 站点 | 是否标记 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| `Failed to connect` / `request:fail` | 仅当 `meta.toast` 打开 | 提示由 `meta.toast` 决定;关掉提示时把兜底交还给边界 |
|
||||
| 401 无登录态 → 跳登录页 | 标记 | 页面已经跳走,等于已告知 |
|
||||
| 刷新令牌失败 → `goLogin` | 标记原错误对象 | 保留 `status` 供调用方分支,只补标记 |
|
||||
| 403 → 跳 403 页 | 标记 | 403 页会列出缺哪条权限,不需要再弹一次 |
|
||||
| 非 2xx 通用分支 | 仅当 `meta.toast` 打开 | `err.status` / `err.data` 保持不变 |
|
||||
| 业务异常(`ValidatedException` / `ServiceErrorException`) | 标记 | `detectBusinessException` 内部已经 modal/toast |
|
||||
| `uploadFile` 状态码异常 / 网络失败 / 业务异常 | 标记 | 三处都先 `notifyError` 再 reject |
|
||||
| `downloadFile` JSON 错误体 / 非 2xx | 标记 | 已提示 |
|
||||
| `downloadFile` 的 `writeFile` / `openDocument` 失败 | **不标记** | 这两处原本静默失败,正是要靠边界补提示 |
|
||||
| `downloadFile` 内部 `uni.request` 网络失败 | **不标记** | 没有任何提示动作 |
|
||||
|
||||
## 5. 业务代码怎么写
|
||||
|
||||
- **想让用户看到失败**:直接 `await`,别 `catch`。边界会记日志并在生产给一句轻提示。
|
||||
- **自己有能力恢复**(重试、占位、表单标红):自己 `catch`,处理完就别再抛;此时边界看不到这个错误,符合预期。
|
||||
- **既要处理又要留痕**:`catch` 里 `console.error` 后按需 `markErrorNotified(err)`,防止上层再抛时重复提示。
|
||||
- **禁止** `try { ... } catch { }` 空吞。确实要忽略的错误,写成 `catch { /* 说明为什么可以忽略 */ }` 并在注释里给出理由。
|
||||
|
||||
## 6. 两端表现差异
|
||||
|
||||
| 场景 | DEV(`import.meta.env.DEV`) | 生产构建 |
|
||||
| --- | --- | --- |
|
||||
| 未捕获错误 | `showModal`:标题带来源(`error` / `rejection`),正文是错误文案前 300 字 | `showToast`:"操作未成功,请稍后重试" |
|
||||
| 已提示过的错误 | 只有 `console.error` | 只有 `console.error` |
|
||||
| 同文案连续报错 | 首条弹窗,其余 3 秒内静默 | 首条 toast,其余 3 秒内静默 |
|
||||
|
||||
开发期弹窗是刻意的:"看到了但没提示"和"确实没报错"必须能一眼区分。生产提示不带内部术语与堆栈,详情只在控制台。
|
||||
|
||||
**弹窗必须排队**:H5 的 `<uni-modal>` 是单例节点,连续两次 `showModal` 会原地改写同一个框,用户只看得见最后一条(实测过)。`showDevModal()` 因此按时间槽排队——每条占用最多 `MODAL_MAX_HOLD_MS = 5000ms`,到点没点"确定"也放行下一条,既不丢展示也不会把后续错误堵死。
|
||||
|
||||
## 7. 验证记录与配方
|
||||
|
||||
### 7.1 已完成的 H5 实测(2026-10-13,登录页临时探针)
|
||||
|
||||
探针:`onLoad` 里 600ms 抛 `new Error("边界探针-error")`、900ms 产生 `Promise.reject(new Error("边界探针-rejection"))`。验证完已删除。
|
||||
|
||||
| 检查项 | 修复前 | 修复后实测 |
|
||||
| --- | --- | --- |
|
||||
| `[global:error]` 日志 | 无 | 1 条(window `error` 监听) |
|
||||
| `[global:rejection]` 日志 | 无 | 1 条(window `unhandledrejection` 监听) |
|
||||
| 弹窗标题 | 一次都没弹 | `未捕获错误(error)` → `未捕获错误(rejection)` |
|
||||
| 排队时刻 | — | 第二条在 t≈5.6s 出现(第一条 t≈0.6s + 5s 时间槽),实测与 `MODAL_MAX_HOLD_MS` 一致 |
|
||||
| 重复提示 | — | 无:uni 钩子与 window 监听没有叠加打两遍,`uni-modal` 节点数恒为 1,无多余 toast |
|
||||
|
||||
### 7.2 复现配方
|
||||
|
||||
1. 任意页面的 `onLoad` 里临时加一句 `setTimeout(() => { throw new Error("边界探针"); }, 600);` 与一句 `setTimeout(() => { Promise.reject(new Error("边界探针-rejection")); }, 900);`;
|
||||
2. H5 跑 `npm run dev`,**强制整页刷新**(env / App.vue 改动不走热更):应看到两条 `[global:*]` 日志与两个先后出现的弹窗;
|
||||
3. 换成发一个必然失败的请求:应只有请求层那一次提示,边界只留日志(说明"已提示"标记生效);
|
||||
4. 小程序 / App 端:用微信开发者工具与 HBuilderX 各跑一次同样两个探针,确认 `[global:*]` 日志是否出现 —— 这一步还没做过,结果请回填 §8。
|
||||
|
||||
`npm run type-check` 与 `npm run lint` 只能证明代码可编译,**不能证明钩子被触发**;改完这块至少跑一遍第 1、2 步。
|
||||
|
||||
## 8. 已知缺口
|
||||
|
||||
- **小程序 / App 端运行时未实测**:H5 已钉死(§7.1),另两端只做到"构建产物里含边界代码"。§7.2 第 4 步跑完前,不能宣称三端都兜住了。
|
||||
- **资源加载错误没接**:`img` / `script` 404 的 `error` 事件不冒泡,需要 `window.addEventListener("error", handler, true)`(capture 阶段)才收得到,目前未挂。
|
||||
- **页面级错误未接管**:`onError` 是 App 级的,某个页面自己用 `errorCaptured` 拦截后就不会冒到这里(目前项目里没人用 `errorCaptured`)。
|
||||
- **提示文案未国际化**:`"操作未成功,请稍后重试"` 与弹窗标题都是硬编码中文,接多语言时应改走 `vue-i18n`,与 `src/locale/lang` 一起维护。
|
||||
- **生产 toast 文案过于笼统**:刻意不带细节,但如果要做错误上报(按 `source` + 文案指纹聚合),`reportGlobalError` 是唯一收口点,加上报只改这一处。
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
适用范围:`src/pages.json`、`src/manifest.json`、`src/router/routes.ts`、`src/router/h5-clean-url.ts`、`src/main.ts`。
|
||||
本文记录"为什么这么设计"和"新增页面要动哪几处",代码里的注释只解释局部行为,跨文件的约束以本文为准。
|
||||
后端地址的 `.env` 分层见 [environment.md](./environment.md),跳转失败与未捕获错误的兜底口径见 [error-handling.md](./error-handling.md),权限判定见 [permission.md](./permission.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -74,6 +75,16 @@ src/pages/dashboard/dashboard.vue pages.json: pages/dashboard/dashboard URL
|
||||
|
||||
`history` 开关的读取路径:编译层特征开关 `__UNI_FEATURE_ROUTER_MODE__`,取自 `manifest.web || manifest.h5` 的 `router.mode`。
|
||||
|
||||
### 3.3 权限评估层(第三轮,见 permission.md)
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --- | --- |
|
||||
| `permission/permission-kernel.ts` | 新增。纯函数判定内核 + 预建索引(`buildPermissionIndex` / `can` / `canAny` / `canOperate` / `actionsOf` / `grantedPermissions`) |
|
||||
| `permission/permission-context.ts` | 新增。接 pinia 档案:`PermissionContext` / `usePermission` / `usePagePermission` / `canVisitPage` |
|
||||
| `permission/permission-kernel.spec.ts` | 新增。59 条断言的可执行校验(esbuild + node 跑,未引测试框架) |
|
||||
| `router/routes.ts` | `PAGE_PERMISSIONS` 值类型放宽为 `PermissionNames`(`string \| string[]`),注释指向 permission.md |
|
||||
| `router/nav-guard.ts` | `evaluate()` 补第三步 `canVisitPage(name)` → `error403` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 短地址机制:追加克隆记录,而不是挂 alias
|
||||
@@ -165,6 +176,7 @@ export const PAGE_PATHS = {
|
||||
2. src/pages.json 追加 { "path": "pages/album/album" }
|
||||
3. src/router/routes.ts 的 PAGE_PATHS 追加 album: "pages/album/album"
|
||||
4. src/pages/album/album.vue 受登录保护的页面调一次 useAuthGuard()
|
||||
5. src/router/routes.ts 的 PAGE_PERMISSIONS 受功能权限控制的页面才登记权限名,见 permission.md §6
|
||||
```
|
||||
|
||||
第 2 步是**绝对必须**:`pages.json` 是编译期唯一注册入口,不写就没有这个页面。
|
||||
@@ -199,7 +211,7 @@ export const PAGE_PATHS = {
|
||||
|
||||
## 8. 当前没有做的事(已知缺口)
|
||||
|
||||
- **权限守卫只做了一半**:登录守卫与 401/404 已落地(§11),但 `PAGE_PERMISSIONS` 还是空表——旧 WebVue 的 `setupPermissionGuard` 依赖 `PermissionContext.canView`(菜单/功能权限清单),那套体系尚未移植,所以页面级 403 目前只由请求层的 HTTP 403 触发。
|
||||
- **权限守卫已落地但登记表是空的**:`PAGE_PERMISSIONS` 尚无条目,路由级 403 因此还没被任何页面真正触发(现在能跳 403 的只有请求层的 HTTP 403)。判定层本身已完成并可用,语义、登记流程与已知缺口见 [permission.md](./permission.md)。
|
||||
- **history 模式的服务端兜底**:`/login`、`/dashboard` 这类深链**直接刷新依赖服务端把请求 rewrite 回 `index.html`**。dev server(Vite)已实测返回 SPA 壳,但生产环境(如 `uat.youjiala.com` 的 Nginx)需要加 `try_files $uri $uri/ /index.html;` 一条,否则刷新会 404。这一条尚未在生产环境验证。
|
||||
|
||||
---
|
||||
@@ -269,6 +281,7 @@ queue: res === false 时返回 { then(){}, catch(){} } 这样的空 thenable
|
||||
- `AUTH_FREE_PAGES` = `login` + 三个错误页。**错误页必须免登录**:401 页就是给"没登录/登录失效"的人看的,拦掉它只会让用户看到一个登录框而不知道刚才为什么被踢。
|
||||
- 登录态只认请求层的 `getJwtInfo()?.AccessToken`(令牌唯一来源,与 `stores/loginProfile` 口径一致)。
|
||||
- 刻意**不**把已登录用户从 `/login` 弹回首页:403 页的"重新登录"按钮需要先能进入登录页。
|
||||
- 第三步(403)走 **fail-open**:本地档案缺失或权限清单为空时放行,只有"档案里有清单但目标页的权限名不在其中"才拦。理由与风险边界见 permission.md §5——真正的安全边界在后端每个接口的 `[PermissionOperate]`。
|
||||
- 跳错误页用 `redirectTo`(保住返回栈,"返回上一页"才有内容),失败再退化成 `reLaunch`。
|
||||
|
||||
### 11.3 错误页与外壳组件
|
||||
@@ -290,12 +303,32 @@ queue: res === false 时返回 { then(){}, catch(){} } 这样的空 thenable
|
||||
### 11.5 403 的两个触发点
|
||||
|
||||
- 请求层:`common/http.interceptor.ts` 的非 2xx 分支里 `statusCode === 403` → `goForbidden()` 跳 403 页并带 `?from=`;已经在 403 页就不再跳,避免"错误页自己发的请求又 403"来回刷。
|
||||
- 导航层:等 `PAGE_PERMISSIONS` 与 `PermissionContext` 移植后接在 `evaluate()` 的第三步。
|
||||
- 导航层:`evaluate()` 的第三步 `if (!canVisitPage(name)) return "error403"`,数据源是 `PAGE_PERMISSIONS` + `PermissionContext`(判定语义、双失败策略、登记流程全部见 [permission.md](./permission.md))。该步排在 401 之后:未登录时档案通常也是空的,先给 401 才谈得上"去登录"。
|
||||
|
||||
`goForbidden()` 在请求层自己实现而没有复用 `nav-guard` 的 `jumpToError`:`nav-guard` 依赖请求层读登录态,反向 import 会形成循环依赖。
|
||||
`goForbidden()` 在请求层自己实现而没有复用 `nav-guard` 的 `jumpToError`:`nav-guard` 依赖请求层读登录态,反向 import 会形成循环依赖。`canVisitPage()` 由 `nav-guard` import `permission/permission-context` 单向调用,不构成环——`permission-context` 只 import `routes.ts` 的常量与 `resolvePageName`,不 import `nav-guard`。
|
||||
|
||||
### 11.6 词条与验证
|
||||
|
||||
- 文案全部走语言包:`locale/lang/{zh-CN,en-US}.json` 的 `ErrorPage` 组 30 个键;组件内固定的"可能的原因""出错的地址:"也在里面(`reasonsTitle` / `fromLabel`)。
|
||||
- 实测(H5 dev + 自动化浏览器):未登录深链 `/dashboard` → 401 页且 `from` 显示正确;401 的"去登录"→ `/login?redirect=/dashboard`;`001/123` 登录后回跳 `/dashboard`;`/no/such/page` → 404 页;404 的"返回首页"放行;清态后直访 `/error/page403` 不被再拦、无死循环;`window.__uniRoutes` 共 10 条(5 真 + 5 短地址克隆),全程零 console error/warn。
|
||||
- 三端构建 `EXIT=0`;`location.pathname` 仅出现在 H5 产物(`setupUnknownPathFallback` 的 `#ifdef H5` 生效),`page401` 三端产物均有。
|
||||
|
||||
### 11.7 `resolvePageName("")` 曾把登录成功后置地址清成空串(已修)
|
||||
|
||||
第三轮接权限层做端到端时暴露的既有缺陷,与权限无关,但正是"空地址到底算不算一个页面"这条边界没定死:
|
||||
|
||||
```text
|
||||
无 ?redirect= 的普通登录
|
||||
-> login.vue onLoad 里 decodeQueryPath(undefined) -> ""
|
||||
-> resolvePageName("") -> normalizePath("") -> "/" -> 命中入口页分支 -> "login"(真值)
|
||||
-> if (name) 成立 -> redirectUrl.value = ""(把默认的 /dashboard 覆盖成空串)
|
||||
-> uni.reLaunch({ url: "" }) -> fail: Missing required args "url"
|
||||
-> 用户点了登录、接口 200、令牌已写入,却仍然卡在登录页
|
||||
```
|
||||
|
||||
`"/"` 作为 H5 入口页的路由 path 必须能被 `resolvePageName` 认出来(冷启动落在入口页时靠它反查页面名),所以不能动那一条;问题在于 `normalizePath` 把空串也归一成 `"/"`,于是"我压根没打算跳"被误读成"我要跳入口页"。修复分两层:
|
||||
|
||||
- `routes.ts` `resolvePageName`:开头对 `!String(url || "").trim()` 直接返回 `null`(源头掐断,空地址永远不算页面)。
|
||||
- `login.vue` `onLoad`:判定条件从 `if (name)` 收紧为 `if (name && !isAuthFree(name))`——手敲 `?redirect=/` 或 `?redirect=/error/page401` 时不再把落地页设成登录页/错误页自己。
|
||||
|
||||
教训:**"`""` 和 `"/"` 等价"这种归一化只该出现在真正需要它的地方**。凡是把字符串地址解析成结构化结果的函数,都要先明确"空输入是错误还是默认值",否则默认值会顺着真值判断悄悄覆盖掉上游的初始化。
|
||||
@@ -0,0 +1,319 @@
|
||||
# 权限评估层(PermissionContext 移植与优化)
|
||||
|
||||
适用范围:`src/permission/*`、`src/router/routes.ts` 的 `PAGE_PERMISSIONS`、`src/router/nav-guard.ts` 的第三步判定、`src/stores/loginProfile.ts` 的 `permissionInfo`。
|
||||
本文记录"语义为什么这样定""相对旧 WebVue 改了哪几处、为什么",代码注释只解释局部行为,跨文件与跨端的约束以本文为准。
|
||||
路由/短地址/401·403·404 的守卫链路见 [page-routing.md](./page-routing.md),本文只讲权限判定本身。
|
||||
403 跳转与请求层提示的去重口径见 [error-handling.md](./error-handling.md),后端地址来源见 [environment.md](./environment.md)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据来源:只有一个,且是登录时的快照
|
||||
|
||||
```
|
||||
POST /Auth/Login
|
||||
-> LoginProfile.PermissionInfo : SecurityUserPermissionInfo
|
||||
├─ UserGroups string[] 当前用户所属组名
|
||||
├─ Permissions [] 功能级授权行(含四个 Indc)
|
||||
├─ PermissionDependents [] 数据级/操作级授权行(多 DependentDetailName)
|
||||
└─ PermissionFunctionMaps [] 权限 -> 功能 -> 页面名 映射
|
||||
-> stores/loginProfile.updateLoginProfile()
|
||||
-> persist.paths 含 "loginProfile.PermissionInfo"(本地缓存水合)
|
||||
-> permission/permission-context.ts 建索引并判定
|
||||
```
|
||||
|
||||
三条硬约束:
|
||||
|
||||
- **不另开权限缓存**。判定层只读 `useLoginProfileStore().permissionInfo`,避免出现第二个数据源。
|
||||
- **权限随令牌一起下发**,所以后端改了授权,前端**必须重新登录才会变**。403 页的主按钮是"重新登录"而不是"刷新",就是这个原因。
|
||||
- 档案是从本地缓存水合的,可能半截或为空——判定层对此的容错见 §5。
|
||||
|
||||
生成实体(`src/data/auto/securityGroupPermissionViewEntity.ts` 等)的字段与内核的 `PermissionRowLike` / `DependentRowLike` 结构兼容,内核**不 import 生成类**:生成器改字段名不该牵连判定逻辑,只要后端 JSON 形状不变就照样工作。
|
||||
|
||||
---
|
||||
|
||||
## 2. 判定语义:以服务端为唯一权威
|
||||
|
||||
对齐目标:`CloudBuilder.Security.Service/Authorization/PermissionContext.cs`。
|
||||
|
||||
| 动作 | 后端判定条件 | 前端 |
|
||||
| --- | --- | --- |
|
||||
| `none` | `Permissions` 里存在该 `PermissionName`(不看任何 Indc) | `can(index, "none", p)` |
|
||||
| `view` | `PermissionName == p && ViewIndc == 'Y'` | `canView` |
|
||||
| `create` | 同上,`CreateIndc == 'Y'` | `canCreate` |
|
||||
| `amend` | 同上,`AmendIndc == 'Y'` | `canAmend` |
|
||||
| `delete` | 同上,`DeleteIndc == 'Y'` | `canDelete` |
|
||||
| 带 `dependent` | **只查 `PermissionDependents`**,条件加 `DependentDetailName == dependent` | 传第二个参数 |
|
||||
|
||||
四条刻意保持"严格"的规则,都是为了让前端不比后端宽:
|
||||
|
||||
1. **`Y` 精确比较**(`YesNoPolicy.YES`),小写 `y`、空串、`undefined` 都不算通过。
|
||||
2. **权限名精确字符串比较**,不做大小写宽松、不做 trim、不做 `'*'` 通配。后端是 `x.PermissionName == permission`,前端放宽只会造成"前端放行、后端 403"。
|
||||
3. **传了 dependent 就不回退功能级**。回退等于比后端宽,同样制造错觉。
|
||||
4. **无管理员短路**。后端没有"system_group 全放行"的分支,前端也不许加——一旦加了,本地就再也复现不出后端的判定结果。
|
||||
|
||||
`none` 存在的意义:后端 `CanNone` 就长这样(只要求挂过权限名),用于"有授权行就允许进入,具体动作交给接口按 Indc 判"。前端保留同款,是为了能逐字复现后端行为,不是为了省事。
|
||||
|
||||
### 2.1 操作型判定(Submit / Approval)
|
||||
|
||||
后端 `[PermissionOperate(name, Operate.X)]` 的 switch(`PermissionHandler.HandleRequirementAsync`):
|
||||
|
||||
| Operate | 后端调用 | 前端等价 |
|
||||
| --- | --- | --- |
|
||||
| `None` | `CanNone(name)` | `canOperate(index, "None", p)` |
|
||||
| `Find` / `Print` | `CanView(name)` | `"Find"` / `"Print"` |
|
||||
| `Insert` | `CanCreate(name)` | `"Insert"` |
|
||||
| `Update` | `CanAmend(name)` | `"Update"` |
|
||||
| `Delete` | `CanDelete(name)` | `"Delete"` |
|
||||
| 其余(`Submit` / `Approval`) | `CanNone(name, operate)` —— **把 operate 字符串当作 `DependentDetailName` 去数据级授权里查** | `"Submit"` / `"Approval"` |
|
||||
|
||||
这张表在代码里只写一处:`permission-kernel.ts` 的 `canOperate()`。它让"提交/审核按钮该不该亮"与"接口会不会被 403"共用同一份映射。种子数据里能直接看到 `Submit`/`Approval`/`Cancel`/`Valid`/`Update` 这类 `dependent_detail_name`(`security_permission_dependent` 表),所以这不是推测出来的形态。
|
||||
|
||||
注意:`CanView(p, dep)` 这类**带 dependent 的四动作**后端 overload 目前只有 `SecurityUserService` 等个别地方用到(`CanView(SecurityPermissionPolicy.CanSeeAllUser)` 是单参形式),页面级数据权限在实际业务里尚未铺开。前端把口子留着,代价只是几个可选参数。
|
||||
|
||||
---
|
||||
|
||||
## 3. 相对旧 WebVue(`D:\Docker\WebVue\src\hooks\PermissionContext.ts`)的差异
|
||||
|
||||
| # | 旧实现 | 新实现 | 理由 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `canView` 只判权限名存不存在,**漏检 `ViewIndc`** | 检 `ViewIndc == 'Y'` | 见 §3.1,这是唯一的**行为修复**,其余都是工程优化 |
|
||||
| 2 | 每次判定 `Permissions?.some(...)` 线性扫;一个页面调 4 次扫 4 遍 | 构造/共享时建 `Map` 索引,判定 O(1) | 相册类页面一屏十几个按钮各判各的,重建成本不该落在渲染路径上 |
|
||||
| 3 | 每次 `usePermissionContext()` 都 `new PermissionContext(...)` | 全站共享一个 `computed` 索引,档案变才重建 | 同上;且 `computed` 天然把"重新登录→权限变→UI 重算"接进依赖追踪 |
|
||||
| 4 | 权限名来自菜单树注入的 `route.meta.permission`(`layout/insideLayout.ts`) | `router/routes.ts` 的 `PAGE_PERMISSIONS` 集中登记 | uni-app 没有 route meta,也没有 `beforeEach`;集中登记还能被 TS 校验 key |
|
||||
| 5 | `PERMISSION_FREE_ROUTE_NAMES = ["Preview","PreviewAudio","PreviewVideo"]` 免检名单 | **删掉**,未登记即不校验 | 新架构下"不登记"就是免检,名单是冗余的第二套开关 |
|
||||
| 6 | 页面里 4 行命令式赋值:`canView.value = pContext.canView(route.meta.permission as string)` 等 | `usePagePermission()` 直接给 5 个 `computed` | 少 4 行、免 `as string`、且权限变了会自动重算(旧写法是一次性快照) |
|
||||
| 7 | 无批量入口 | `canAny(index, action, string \| string[])`:任一命中即通过 | 一个页面挂在多个功能权限下(相册浏览同时属于 `PublishPhoto` 与 `FamilyGroup`) |
|
||||
| 8 | 无"一次取全"入口 | `actionsOf()` / `flags()` / `perm.flags` | 整页按钮渲染一次读完,不重复查 |
|
||||
| 9 | 无排查手段 | `granted(action)` 返回已授权权限名清单 | 写错权限名是最难自查的故障,需要反查工具 |
|
||||
| 10 | 无操作型判定 | `canOperate()` | 见 §2.1 |
|
||||
|
||||
兼容方面保留**同名同 API**:`PermissionContext` 类(`canNone/canView/canCreate/canAmend/canDelete`)与 `usePermissionContext()` 都在,旧页面代码可以原样搬过来再逐步换成 composable。
|
||||
|
||||
### 3.1 为什么必须补检 `ViewIndc`(种子数据 + 实测双重佐证)
|
||||
|
||||
种子数据 `Data/Init-builder-202609240910.sql` 里就有这种行:
|
||||
|
||||
```sql
|
||||
-- security_group_permission:挂了权限名,但四个标全 N
|
||||
('vip_group','Welcome','N','N','N','N','')
|
||||
('system_group','LoginAgent','N','N','N','N','')
|
||||
-- 只读型授权:能看不能改
|
||||
('vip_group','SecurityUser','Y','N','N','N','SecurityUser')
|
||||
```
|
||||
|
||||
账号 `001` 登录后档案里的实际取值(`PermissionInfo.Permissions` 共 22 行,取自 `localStorage.loginProfile`):
|
||||
|
||||
```
|
||||
Welcome|N|N|N|N <-- 权限名挂着,后端 CanView = false
|
||||
LoginAgent|N|N|N|N
|
||||
PublishPhoto|Y|Y|Y|Y
|
||||
SecurityUser|Y|Y|Y|Y 与 SecurityUser|Y|N|N|N (两组各授一份,取 OR)
|
||||
```
|
||||
|
||||
旧实现下,`Welcome`、`LoginAgent` 这类"挂了权限名但一个动作都没给"的行会让 `canView` 返回 **true**:用户进得来页面,页面一调列表接口就是 `Find -> CanView -> 403`,表现成"打开就一片红"。补检之后,进不来就直接跳 403 页,原因写在页面上,而不是让用户对着一堆失败的请求猜。
|
||||
|
||||
**这条修复带来的直接后果:`dashboard` 不能绑 `Welcome`。** 库里 `Welcome` 的 `view_indc='N'`,旧前端靠漏检歪打正着地放行了欢迎页;新前端如果照抄"每页都绑权限"的形式,会把所有人(包括管理员组)挡在欢迎页外面。所以现在 `PAGE_PERMISSIONS` 里**没有** `dashboard` 条目——不登记即不校验,这正是 §3 第 5 条免检机制的用意(端到端实测见 §7.2)。
|
||||
|
||||
同理,`divider` 是菜单分隔线(`security_permission` 里就叫 `divider`,`security_function` 里对应 `security_line_1` 等),管理员组对它的授权甚至是 `Y|Y|Y|Y`——授权标位对它是无意义的填值,所以**绝不能用"Indc 有 Y"来反推"这是个真功能"**,它也不是页面权限,任何情况下都不该登记到 `PAGE_PERMISSIONS`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 文件与 API
|
||||
|
||||
| 文件 | 职责 | 依赖 |
|
||||
| --- | --- | --- |
|
||||
| `permission/permission-kernel.ts` | 纯函数 + 索引,判定语义全在这 | 只依赖 `data/custom/yesNoPolicy` |
|
||||
| `permission/permission-context.ts` | 接 pinia、响应式、页面级锁、路由级判定 | kernel + `router/routes` + `stores` |
|
||||
| `permission/permission-kernel.spec.ts` | 可执行校验(不引测试框架) | kernel |
|
||||
| `router/routes.ts` `PAGE_PERMISSIONS` | 页面 -> 权限名登记表 | 只 import kernel 的 `PermissionNames` 类型 |
|
||||
| `router/nav-guard.ts` | 404 → 401 → **403** 三段判定 | `canVisitPage()` |
|
||||
|
||||
内核导出:`buildPermissionIndex` / `can` / `canAny` / `canOperate` / `actionsOf` / `grantedPermissions` / `dependentKey`,类型 `PermissionAction`、`PermissionOperate`、`PermissionNames`、`PermissionIndex`、`*Like`。
|
||||
|
||||
context 导出:
|
||||
|
||||
| API | 用途 |
|
||||
| --- | --- |
|
||||
| `usePermission()` | 页面/组件里最常用的入口:`canView/canCreate/canAmend/canDelete/canNone/can/canOperate/granted`(函数形式,读共享索引) |
|
||||
| `usePagePermission(name?)` | 页面级五把 `computed` 锁 + `flags`;省略 `name` 时按页面栈推断,但 `onLoad` 早期栈可能还没压上当前页,**能显式传就显式传** |
|
||||
| `usePermissionContext()` / `PermissionContext` | 旧 API,一次性快照;移植旧代码时用 |
|
||||
| `canVisitPage(name)` | 路由级判定,只由 `nav-guard` 调用 |
|
||||
| `pagePermission(name)` / `currentPageName()` | 登记表读取与栈顶反查 |
|
||||
|
||||
模板用法(小程序/App 只能这样,见 §6.1):
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { usePagePermission } from "@/permission/permission-context";
|
||||
|
||||
// 页面绑定的权限名由 routes.ts 的 PAGE_PERMISSIONS 登记,页面只问"有没有"
|
||||
const perm = usePagePermission("albumList");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<u-button v-if="perm.canCreate" text="新建相册" />
|
||||
<u-button v-if="perm.canDelete" text="删除" type="error" />
|
||||
<!-- 提交按钮:与后端 [PermissionOperate(name, Operate.Submit)] 同口径 -->
|
||||
<u-button v-if="canOperate('Submit', 'PublishPhoto')" text="提交" />
|
||||
</template>
|
||||
```
|
||||
|
||||
数据级判定(同一功能在不同群组/目录下动作不同):
|
||||
|
||||
```ts
|
||||
const { can, canOperate } = usePermission();
|
||||
can("amend", "FamilyGroup", familyId); // 只查 PermissionDependents
|
||||
canOperate("Approval", "PublishPhoto"); // 走 operate -> dependent 那条线
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 两套失败策略:路由 fail-open,按钮 fail-closed
|
||||
|
||||
这不是不一致,是故意的。
|
||||
|
||||
| 层 | 判定入口 | 档案缺失/权限清单为空时 | 理由 |
|
||||
| --- | --- | --- | --- |
|
||||
| 路由 | `canVisitPage()` | **放行**(fail-open) | 档案来自本地缓存,一旦被写坏或尚未水合,严格模式会把所有人挡在 403 外面——那时用户连"能不能重新登录"都点不到。真正的安全边界始终在后端(每个接口都有 `[PermissionOperate]`) |
|
||||
| 按钮 | `usePagePermission()` / `usePermission()` | **不显示**(fail-closed) | 多挡一个按钮只是少个入口;误放一个按钮,点了只会收到 403 |
|
||||
|
||||
`permissionInfoUnusable()` 只认一个信号:`Permissions` 数组缺失或长度为 0。它**不**区分"这人真没权限"和"档案坏了"——前端分不出来,所以把这个歧义交给后端裁决,而不是自己猜。
|
||||
|
||||
由此推出两条排查纪律:
|
||||
|
||||
- 页面进不去,先看 `PAGE_PERMISSIONS` 有没有登记错权限名(登记错 = 该页对所有人 403,除非档案是空的)。
|
||||
- 按钮不见了,先 `usePermission().granted()` 打印实际授权,再对照后端组授权。
|
||||
|
||||
---
|
||||
|
||||
## 6. 新增页面时怎么登记权限
|
||||
|
||||
```
|
||||
1. 拿到权限名:后端 security_permission.permission_name(权威清单)
|
||||
或 security_permission_function_map(function_name <-> permission_name <-> page_name)
|
||||
2. 写进 src/router/routes.ts 的 PAGE_PERMISSIONS:
|
||||
export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {
|
||||
albumList: "PublishPhoto", // 单个
|
||||
preview: ["PublishPhoto", "FamilyGroup"], // 数组 = 任一命中即放行
|
||||
};
|
||||
3. 页面里用 usePagePermission("albumList") 控制按钮,不要自己写权限名
|
||||
```
|
||||
|
||||
三条纪律:
|
||||
|
||||
- **权限名必须是后端库里的原文**,大小写、拼写逐字一致。写错不会编译报错,只会让该页对所有人 403(§5 已述)。
|
||||
- **能不登记就不登记**。欢迎页、错误页、纯展示页都不该绑权限;绑一个库里全 `N` 的权限(如 `Welcome`)等于自断门路。
|
||||
- **权限名不散落在页面里**。页面只引用 `PageName`,名字集中在 `PAGE_PERMISSIONS`,这样"哪些页面受权限管"是一张可审阅的表,而不是 grep 全项目。
|
||||
|
||||
### 6.1 跨端注意
|
||||
|
||||
- **小程序不支持自定义指令**,所以不要写 `v-perm`/`v-auth` 之类的指令(App 端同样受编译期限制)。本项目统一用 composable + `v-if`,三端一致。
|
||||
- 判定不碰 `uni.*` API,也不碰请求层,所以 `permission-kernel.ts` 可以在 node 下直接跑(§7)。
|
||||
- 无 `router.beforeEach`:路由级判定只有 `nav-guard` 的两条链路(拦截器 + 页面 `onShow` 复核),权限层挂在它的第三步,顺序固定在 401 之后——未登录时档案通常也是空的,先给 401 才谈得上"去登录"。
|
||||
|
||||
### 6.2 为什么不用已经在手里的 `PermissionFunctionMaps` 生成登记表
|
||||
|
||||
登录档案已经带了 `PermissionFunctionMaps[]`(`{PermissionName, FunctionName, PageName}`),看起来可以直接推导出 `PAGE_PERMISSIONS`。**不能**:后端构造它时 `join info.Permissions`(`PermissionContext.cs:68-73`),只包含**当前用户已授权**的权限。用它登记,"该页需要但当前用户没有"的权限名会直接缺席 → 该页变成不校验 → 谁都能进,恰好把权限体系掏空。
|
||||
|
||||
它的正确用途在别处:**登记与排查时的"页面名 <-> 权限名"反查表**(`security_permission_function_map` 视图,字段只有 `PermissionName/FunctionName/PageName`)。它画不出菜单——没有 `FunctionLocalName/FunctionEngName`、没有 `Icon`、没有 `Orderby`、没有父子层级,做菜单要用下面 §8 说的 `FindMenuByUsername`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 校验:内核是可执行的
|
||||
|
||||
项目还没接测试运行器,所以 `permission-kernel.spec.ts` 写成"能被 esbuild 打包、node 直接跑"的形态。跑法(PowerShell,项目根目录):
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files\nodejs\node.exe" ".\node_modules\esbuild\bin\esbuild" `
|
||||
src\permission\permission-kernel.spec.ts --bundle --platform=node --format=cjs `
|
||||
--alias:@=./src --outfile="$env:TEMP\permission-kernel.spec.cjs" --log-level=warning
|
||||
& "C:\Program Files\nodejs\node.exe" "$env:TEMP\permission-kernel.spec.cjs" 2>&1 `
|
||||
| Out-File -Encoding utf8 "$env:TEMP\perm-run.log"
|
||||
"RUN_EXIT=$LASTEXITCODE"
|
||||
```
|
||||
|
||||
`RUN_EXIT=0` 且日志尾部为 `permission-kernel: 59 assertions passed` 即通过(断言失败会打印 `FAIL <用例名>` 并以非零退出,不会"跑了但没人看输出")。
|
||||
|
||||
覆盖的 10 组语义:`view` 必须检 `ViewIndc`、四动作互相独立、多用户组授权 OR 合并、数据级不回退、严格比较(小写 `y`/大小写/不 trim)、档案缺失或损坏不抛异常、`canAny` 数组与空串、未授予权限返回全 `false`、`grantedPermissions` 清单、`canOperate` 全操作映射(含 `Submit` 命中与 `Approval` 未挂)。
|
||||
|
||||
将来引入 vitest,只需把 `check()` 换成 `expect()`,用例本身不动。**判定语义改动必须先加用例再改代码**——`canView` 那条修复就是靠用例钉住的,光读代码很容易自我说服"应该没问题"。
|
||||
|
||||
### 7.2 端到端实测(H5 dev + 真实后端,账号 001)
|
||||
|
||||
`PAGE_PERMISSIONS` 是空表,路由级 403 在日常代码里走不到,所以验证时临时往表里塞值、跑完再回滚。方法:`npm run dev:h5`(5173)+ 后端 5235,浏览器真机登录,观察地址栏与 console。三轮结论:
|
||||
|
||||
| 临时绑定 | 预期 | 实测 |
|
||||
| --- | --- | --- |
|
||||
| `dashboard: "TEMP_E2E_NOT_EXIST"` | 登录后与冷启动深链都进不去 | ✅ 两条链路都落 `/error/page403?from=/dashboard`,0 console error;401→403 的 `?from=` 传递与"重新登录带 redirect"一并复验 |
|
||||
| `dashboard: "Welcome"`(库里真实存在,但 `view_indc='N'`) | 仍拦 | ✅ 登录后 2.5s 落到 403。**这就是 §3.1 那条修复的端到端实证**——旧前端在此处会误放行,然后满屏接口 403 |
|
||||
| `dashboard: ["PublishVideo", "PublishPhoto"]`(均 `Y|Y|Y|Y`) | 放行 | ✅ 登录跳转与冷启动深链都正常进 `/dashboard`,0 error / 0 warn;顺带在页面运行时直呼线上内核复现了数组 OR 语义:`canAny(view, ["TEMP_NO_SUCH","PublishPhoto"]) -> true`、`canAny(view, ["TEMP_NO_SUCH"]) -> false` |
|
||||
|
||||
三轮都只用临时值,`PAGE_PERMISSIONS` 现已回滚为 `{}`。**验证权限层时不要只看"能不能进",必须同时看 console**——拦截逻辑本身没问题时,残余故障通常表现为多打了几个接口或重复跳转。
|
||||
|
||||
顺带发现并修掉一个既有缺陷(与权限无关,但正是这轮端到端才暴露的):登录页缺 `?redirect=` 时,`resolvePageName("")` 会经 `normalizePath("") -> "/" -> "login"` 返回真值,于是 `login.vue` 的 `onLoad` 把默认落地地址覆盖成空串,`uni.reLaunch({url: ""})` 直接 `fail: Missing required args "url"`,用户登录成功后卡在登录页。修复分两处:`routes.ts` 的 `resolvePageName` 对空地址早退返回 `null`(源头),`login.vue` 追加 `!isAuthFree(name)`(不让手敲的 `?redirect=/` 把自己跳回登录页)。
|
||||
|
||||
### 7.3 改完权限层必跑的四步(本机实测耗时约 2 分钟)
|
||||
|
||||
项目根目录,PowerShell。`node` 必须用绝对路径(PATH 上那个是吞输出的垫片):
|
||||
|
||||
```powershell
|
||||
$n = 'C:\Program Files\nodejs\node.exe'
|
||||
# 1) 内核语义(§7) 期望 SPEC_EXIT=0 且日志含 "59 assertions passed"
|
||||
& $n ".\node_modules\esbuild\bin\esbuild" src\permission\permission-kernel.spec.ts --bundle --platform=node --format=cjs --alias:@=./src --outfile="$env:TEMP\permission-kernel.spec.cjs" --log-level=warning
|
||||
& $n "$env:TEMP\permission-kernel.spec.cjs" 2>&1 | Out-File -Encoding utf8 "$env:TEMP\perm-run.log"; "SPEC_EXIT=$LASTEXITCODE"
|
||||
# 2) 类型(含 IDE 误报的裁决) 期望 VTSC_EXIT=0 且 VTSC_BYTES=0
|
||||
& $n ".\node_modules\vue-tsc\bin\vue-tsc.js" --noEmit 2>&1 | Out-File -Encoding utf8 "$env:TEMP\vt.log"; "VTSC_EXIT=$LASTEXITCODE VTSC_BYTES=" + (Get-Item "$env:TEMP\vt.log").Length
|
||||
# 3) Lint(含 md 里的 ts 代码块) 期望 ESLINT_EXIT=0
|
||||
& $n ".\node_modules\eslint\bin\eslint.js" src -f stylish -o "$env:TEMP\lint.txt"; "ESLINT_EXIT=$LASTEXITCODE"
|
||||
# 4) 三端构建 期望三份日志都是 DONE Build complete.
|
||||
.\node_modules\.bin\uni.cmd build *> "$env:TEMP\b-h5.log"
|
||||
.\node_modules\.bin\uni.cmd build -p mp-weixin *> "$env:TEMP\b-mp.log"
|
||||
.\node_modules\.bin\uni.cmd build -p app *> "$env:TEMP\b-app.log"
|
||||
```
|
||||
|
||||
**别把"没回显"当成"卡死"**:`uni.cmd` 的输出经 PowerShell 缓冲,三条链式命令跑完才一次性刷出来,中途取消会让你只看到 `ExitCode 130` 而看不到 `H5_EXIT=`。此时看两处实锤就够了:
|
||||
|
||||
```powershell
|
||||
Get-Item "$env:TEMP\b-h5.log","$env:TEMP\b-mp.log","$env:TEMP\b-app.log" | Select-Object Length, LastWriteTime
|
||||
Select-String -Path "$env:TEMP\b-*.log" -Pattern 'Build complete'
|
||||
```
|
||||
|
||||
三端单次构建各约 10 秒,整条链 35 秒上下;日志里有 `DONE Build complete.` 且 `dist\build\{h5,mp-weixin,app}` 的 `LastWriteTime` 是刚刚,就是成功。想确认权限层真的进了产物,搜后端 JSON 字段名(压缩后属性名不变):
|
||||
|
||||
```powershell
|
||||
Get-ChildItem 'dist\build\mp-weixin' -Recurse -File -Include *.js | Select-String -Pattern 'PermissionDependents' -List
|
||||
```
|
||||
|
||||
**别用 `Select-String -Path 'dist\build\mp-weixin\**\*.js'`**:PowerShell 的 `**` 通配在这里不递归,会给出假的"0 命中"(本轮就这样误判过一次"小程序产物没吃到 .env",实际是搜索方式错了)。
|
||||
|
||||
改完请求层或全局错误边界,还要按 [error-handling.md §7.2](./error-handling.md) 在浏览器里跑一次探针:静态四步全绿也证明不了钩子会被触发。
|
||||
|
||||
**两处刻意绕开外部格式化器的写法**(`permission-kernel.ts`):IDE 的 prettier 与项目 ESLint 在两个构造上各执一词,`--fix` 之后一保存又被改回去,来回拉锯且每次都以 `ESLINT_EXIT=1` 收场,所以直接换成两边都稳定的形态,而不是每次收尾都跑一遍 `--fix`:
|
||||
|
||||
| 构造 | 冲突 | 现在的写法 |
|
||||
| --- | --- | --- |
|
||||
| `canAny` 的逐项判定 | prettier 给单参数箭头加括号,`style/arrow-parens`(项目规范:单参数必须省括号)又要去掉 | `for (const name of list)`,语义与 `some()` 的短路完全一致 |
|
||||
| `PermissionOperate` 联合类型 | 多行字面量联合的 `=` 位置:prettier 放行尾,`style/operator-linebreak` 放行首 | `PERMISSION_OPERATES` 用 `as const` 数组 + `(typeof ...)[number]` 派生,单行放得下;顺带得到可运行的枚举清单 |
|
||||
|
||||
自查方法:连跑两遍 `eslint src`,中间 `Start-Sleep` 几秒等格式化器动手——两遍都 `EXIT=0` 才算真的稳定,只跑一遍容易把"刚被 --fix 修好、马上又会被格式化回去"误判成通过。
|
||||
|
||||
---
|
||||
|
||||
## 8. 已知缺口
|
||||
|
||||
- **入口可见性这一层还没做(菜单/首页九宫格)**。权限的可见性分两层,本层只实现了第二层:
|
||||
|
||||
| 层 | 管什么 | 数据源 | 现状 |
|
||||
| --- | --- | --- | --- |
|
||||
| ① 入口可见性 | 首页列不列得出这个入口 | `POST /SecurityUser/FindMenuByUsername`(后端按用户权限过滤) | **未实现**:`dashboard.vue` 是空白占位页 |
|
||||
| ② 页面可进入性 | 手输地址/深链跳过去能不能进 | `PAGE_PERMISSIONS` + `canVisitPage()` | 判定层就绪,登记表为空 |
|
||||
|
||||
旧 WebVue 主要靠 ①:看不见的入口自然不会点,②只是补"手敲 URL"的漏洞。①的后端链路(`SecurityUserService.cs:232-248`)是 `用户名 -> security_user_actor -> security_actor_group -> security_group_permission -> security_permission_function_map -> security_function`,只返回本人被授权的功能,`UserId` 为空直接返回 null。
|
||||
两个接口都已随 service 层移植进来且**零调用者**:`securityUserService.FindMenuByUsername()`(返回 `SecurityFunctionResultData`,含 `SecurityFunctionDisplayData[]` 的 `FunctionLocalName/FunctionEngName/Icon/Orderby/PageUrl/PermissionName` 与 `SecurityFunctionMaps` 层级,正是旧 `layout/index.vue` 的 `initMenu()` 消费的形态)和 `securityFunctionService.FindMenus()`(`[AllowAnonymous]`、**不区分用户的全量表**,旧项目只用来全量注册路由,别拿它做过滤)。
|
||||
所以这一条缺口的真实含义是:等做首页入口时调 `FindMenuByUsername` 渲染,并用返回的 `PermissionName` 顺手校对 `PAGE_PERMISSIONS` 的登记;`stores/tabbar.ts` 的默认列表目前也是空的,等同一个数据源。
|
||||
- **数据级(dependent)判定尚无真实页面在用**:旧项目所有调用点都是单参形式,口子为 `Submit`/`Approval` 与将来的目录级授权保留。
|
||||
- **`PAGE_PERMISSIONS` 目前是空表**:现在只有 login / dashboard / 三个错误页,除 dashboard 有意不绑(§3.1)之外,其余页面还没迁进来。空表意味着路由级 403 实际上还没被任何页面触发,只能靠请求层的 HTTP 403。
|
||||
- **403 页的"返回首页"在首页本身被拦时会原地弹回一次**:`page403.vue` 的返回按钮走 `reLaunch(pageUrl("dashboard"))`,而 reLaunch 同样过 `nav-guard`;若 dashboard 恰好被绑了拿不到的权限,这次跳转会被再拦一次、弹回 403(实测是一次性弹回,不是死循环,因为 403 页自身在 `AUTH_FREE_PAGES` 里)。等真有页面登记权限时,这里应该改成"按 `PAGE_PERMISSIONS` 找第一个可达页"或直接隐藏该按钮。
|
||||
- **前端判定滞后于后端**:后端每次请求实时查库,前端读登录时的快照。管理员改了授权后,用户重新登录前看到的仍是旧按钮状态(可能点了才 403)。这是"权限随令牌下发"这个既有模型的代价,不是本层引入的。
|
||||
- **`SecurityUserPermissionInfo.UserGroups` 未参与判定**:后端也不用它做短路(只用于 join 出授权行),前端同样只用 `Permissions` / `PermissionDependents`。
|
||||
- **孤儿授权数据**:种子库里 `security_group_permission` 有 `abc` 组以及 `acc`/`PhotoPublish`/`AudioPublish`/`VideoPublish` 等权限名,而 `security_group` / `security_permission` 里查不到它们。判定层对此的表现是"多几行永远匹配不上的索引项",无害;但登记权限名时**不能以授权表的并集为准**,要以 `security_permission` 为准,否则会照抄到这些历史残留。
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 403 无权限页,移植自旧 WebVue 的 views/error/page403.vue
|
||||
// 谁来跳:请求层拿到 HTTP 403(common/http.interceptor)时带原地址跳本页;
|
||||
// 页面级权限校验(旧 setupPermissionGuard)等 PAGE_PERMISSIONS 接入后接在 nav-guard
|
||||
// 谁来跳两处:1) 请求层拿到 HTTP 403(common/http.interceptor)时带原地址跳本页;
|
||||
// 2) 导航守卫第三步 canVisitPage 不通过(router/nav-guard + permission/permission-context)
|
||||
// 权限判定语义见 src/md/permission.md
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { useLocale } from "uview-pro";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { backendLocale, baseUrl } from "@/common/http.interceptor";
|
||||
import { encryptLoginPassword } from "@/common/pwd-crypto";
|
||||
import { LoginData as LoginDataClass } from "@/data/auto/loginData";
|
||||
import { reloadLocales } from "@/locale";
|
||||
import { decodeQueryPath, pageUrl, resolvePageName } from "@/router/routes";
|
||||
import { decodeQueryPath, isAuthFree, pageUrl, resolvePageName } from "@/router/routes";
|
||||
import authService from "@/service/security/authService";
|
||||
import { useLoginProfileStore } from "@/stores";
|
||||
|
||||
@@ -27,7 +27,10 @@ const loginProfileStore = useLoginProfileStore();
|
||||
const redirectUrl = ref(pageUrl("dashboard"));
|
||||
onLoad((options) => {
|
||||
const target = decodeQueryPath(options?.redirect);
|
||||
if (resolvePageName(target)) {
|
||||
const name = resolvePageName(target);
|
||||
// 还要求"不是免登录页":把手敲的 ?redirect=/ 或 ?redirect=/error/page401 放过来,
|
||||
// 登录后要么原地回到登录页,要么落到一个写着"你没登录"的页面
|
||||
if (name && !isAuthFree(name)) {
|
||||
redirectUrl.value = target;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 权限上下文:把 permission-kernel 接到 pinia 登录档案上
|
||||
*
|
||||
* 数据来源只有一个:useLoginProfileStore().permissionInfo
|
||||
* (登录接口 /Auth/Login 返回的 SecurityUserPermissionInfo,已在 store 的 persist.paths 里持久化)。
|
||||
* 不再另开一份权限缓存,避免和档案不同源。
|
||||
*
|
||||
* 两套判定策略,故意的:
|
||||
* 1) 路由级(canVisitPage)走 fail-open——本地档案缺失、权限清单为空时放行。
|
||||
* 理由与旧项目一致:档案是从本地缓存水合的,一旦过期或被写坏成空档案,
|
||||
* 严格模式会把所有人全挡在 403 外面,而真正的安全边界在后端(每个接口都有 [PermissionOperate])。
|
||||
* 2) 按钮级(usePagePermission / usePermission)走 fail-closed——没有授权证据就不显示。
|
||||
* 多挡一个按钮只是少个入口,误放一个按钮却会让人点了才收到 403。
|
||||
*/
|
||||
import type { ComputedRef } from "vue";
|
||||
import type {
|
||||
PermissionAction,
|
||||
PermissionIndex,
|
||||
PermissionInfoLike,
|
||||
PermissionNames,
|
||||
PermissionOperate,
|
||||
} from "@/permission/permission-kernel";
|
||||
import type { PageName } from "@/router/routes";
|
||||
import { computed } from "vue";
|
||||
import {
|
||||
actionsOf,
|
||||
buildPermissionIndex,
|
||||
can,
|
||||
canAny,
|
||||
canOperate,
|
||||
grantedPermissions,
|
||||
} from "@/permission/permission-kernel";
|
||||
import { PAGE_PERMISSIONS, resolvePageName } from "@/router/routes";
|
||||
import { useLoginProfileStore } from "@/stores";
|
||||
|
||||
/** 权限名支持数组:任一通过即通过(一个页面挂在多个功能权限下);定义在内核,此处转出便于页面直接使用 */
|
||||
export type { PermissionNames } from "@/permission/permission-kernel";
|
||||
|
||||
/**
|
||||
* 权限快照(与旧 WebVue 的 hooks/PermissionContext.ts 同名同 API,
|
||||
* 便于旧页面代码继续移植;区别是这里构造时建一次索引,四个方法都是 O(1))
|
||||
*/
|
||||
export class PermissionContext {
|
||||
private readonly index: PermissionIndex;
|
||||
|
||||
constructor(info?: PermissionInfoLike | null) {
|
||||
this.index = buildPermissionIndex(info);
|
||||
}
|
||||
|
||||
canNone(permission: PermissionNames): boolean {
|
||||
return canAny(this.index, "none", permission);
|
||||
}
|
||||
|
||||
canView(permission: PermissionNames, dependentId?: string): boolean {
|
||||
return canAny(this.index, "view", permission, dependentId);
|
||||
}
|
||||
|
||||
canCreate(permission: PermissionNames, dependentId?: string): boolean {
|
||||
return canAny(this.index, "create", permission, dependentId);
|
||||
}
|
||||
|
||||
canAmend(permission: PermissionNames, dependentId?: string): boolean {
|
||||
return canAny(this.index, "amend", permission, dependentId);
|
||||
}
|
||||
|
||||
canDelete(permission: PermissionNames, dependentId?: string): boolean {
|
||||
return canAny(this.index, "delete", permission, dependentId);
|
||||
}
|
||||
|
||||
/** 某权限(或某条数据级授权)上四种动作的完整结果,一次取全给整页按钮用 */
|
||||
flags(permission: string, dependentId?: string) {
|
||||
return actionsOf(this.index, permission, dependentId);
|
||||
}
|
||||
|
||||
/** 后端 [PermissionOperate(name, Operate.X)] 同款判定,含 Submit/Approval */
|
||||
canOperate(operate: PermissionOperate, permission: string): boolean {
|
||||
return canOperate(this.index, operate, permission);
|
||||
}
|
||||
}
|
||||
|
||||
// 共享索引:全站建一次,档案变化时由 computed 自动失效重建。
|
||||
// 旧实现是每次 usePermissionContext() 都 new 一个上下文、每个判定都线性扫数组;
|
||||
// 相册这类页面一屏十几个按钮各判各的,重建成本会落到渲染路径上。
|
||||
let sharedIndex: ComputedRef<PermissionIndex> | null = null;
|
||||
|
||||
function permissionIndex(): ComputedRef<PermissionIndex> {
|
||||
if (!sharedIndex) {
|
||||
const store = useLoginProfileStore();
|
||||
sharedIndex = computed(() => buildPermissionIndex(store.permissionInfo));
|
||||
}
|
||||
return sharedIndex;
|
||||
}
|
||||
|
||||
/** 取当前档案的索引;在渲染/判定期间读 .value,等于把"档案变→重新判定"交给 Vue 依赖追踪 */
|
||||
function currentIndex(): PermissionIndex {
|
||||
return permissionIndex().value;
|
||||
}
|
||||
|
||||
/** 当前登录用户的权限快照;档案为空时得到空索引(判定一律 false) */
|
||||
export function usePermissionContext(): PermissionContext {
|
||||
const store = useLoginProfileStore();
|
||||
return new PermissionContext(store.permissionInfo);
|
||||
}
|
||||
|
||||
/** 组合式判定:函数形式,读的是共享索引,随档案变化自动生效 */
|
||||
export function usePermission() {
|
||||
return {
|
||||
canNone: (permission: PermissionNames) =>
|
||||
canAny(currentIndex(), "none", permission),
|
||||
canView: (permission: PermissionNames, dependentId?: string) =>
|
||||
canAny(currentIndex(), "view", permission, dependentId),
|
||||
canCreate: (permission: PermissionNames, dependentId?: string) =>
|
||||
canAny(currentIndex(), "create", permission, dependentId),
|
||||
canAmend: (permission: PermissionNames, dependentId?: string) =>
|
||||
canAny(currentIndex(), "amend", permission, dependentId),
|
||||
canDelete: (permission: PermissionNames, dependentId?: string) =>
|
||||
canAny(currentIndex(), "delete", permission, dependentId),
|
||||
/** 数据级或动作型判定的统一入口,如 can("view", "FamilyGroupShare", familyId) */
|
||||
can: (action: PermissionAction, permission: string, dependentId?: string) =>
|
||||
can(currentIndex(), action, permission, dependentId),
|
||||
/** 与后端接口鉴权同口径的操作判定,如 canOperate("Submit", "PublishPhotoEnquiry") */
|
||||
canOperate: (operate: PermissionOperate, permission: string) =>
|
||||
canOperate(currentIndex(), operate, permission),
|
||||
/** 已授权的权限名清单,调试与"我有什么权限"排查用 */
|
||||
granted: (action: PermissionAction = "none") =>
|
||||
grantedPermissions(currentIndex(), action),
|
||||
};
|
||||
}
|
||||
|
||||
/** 页面绑定的权限名;未登记表示该页不做权限校验 */
|
||||
export function pagePermission(name: PageName): PermissionNames | undefined {
|
||||
return PAGE_PERMISSIONS[name];
|
||||
}
|
||||
|
||||
/** 当前页面名:按页面栈栈顶的真实路径反查(拿不到返回 null) */
|
||||
export function currentPageName(): PageName | null {
|
||||
const pages = getCurrentPages();
|
||||
const current = pages[pages.length - 1];
|
||||
return current ? resolvePageName(`/${current.route}`) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面级四把锁,模板里直接 v-if="perm.canDelete" 用。
|
||||
* @param name 页面名;省略时按页面栈推断,但在 onLoad 早期栈可能还没压上当前页,
|
||||
* 所以能显式传就显式传,省得依赖一个时机敏感的行为
|
||||
*/
|
||||
export function usePagePermission(name?: PageName) {
|
||||
const target = name ?? currentPageName();
|
||||
const permission = target ? pagePermission(target) : undefined;
|
||||
// 没绑定权限名的页面(欢迎页、错误页等)四个锁全开:它不属于权限体系管的事
|
||||
const pass = () => !permission;
|
||||
const flags = computed(() => {
|
||||
if (!permission) {
|
||||
return {
|
||||
none: true,
|
||||
view: true,
|
||||
create: true,
|
||||
amend: true,
|
||||
delete: true,
|
||||
};
|
||||
}
|
||||
const list = Array.isArray(permission) ? permission : [permission];
|
||||
// 多个权限名时取并集:任一给了该动作即算有
|
||||
const merged = {
|
||||
none: false,
|
||||
view: false,
|
||||
create: false,
|
||||
amend: false,
|
||||
delete: false,
|
||||
};
|
||||
for (const item of list) {
|
||||
const one = actionsOf(currentIndex(), item);
|
||||
merged.none ||= one.none;
|
||||
merged.view ||= one.view;
|
||||
merged.create ||= one.create;
|
||||
merged.amend ||= one.amend;
|
||||
merged.delete ||= one.delete;
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
return {
|
||||
pageName: target ?? null,
|
||||
permission: permission ?? null,
|
||||
/** 一次拿全五种结果,模板里频繁判定时比四个 computed 少读四次索引 */
|
||||
flags,
|
||||
canNone: computed(() => pass() || flags.value.none),
|
||||
canView: computed(() => pass() || flags.value.view),
|
||||
canCreate: computed(() => pass() || flags.value.create),
|
||||
canAmend: computed(() => pass() || flags.value.amend),
|
||||
canDelete: computed(() => pass() || flags.value.delete),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录档案里的权限清单是否不可信(缺失/空)。
|
||||
* 空清单既可能是"这人真没权限",也可能是"本地档案被写坏或还没水合",
|
||||
* 前端分不出这两种情况,所以只在路由级选择放行,把裁决权交回后端。
|
||||
*/
|
||||
function permissionInfoUnusable(): boolean {
|
||||
const info = useLoginProfileStore().permissionInfo;
|
||||
return !info?.Permissions?.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由级判定:能不能进这个页面。
|
||||
* 顺序上必须在登录校验之后(未登录该给 401 而不是 403),由 nav-guard 保证。
|
||||
*/
|
||||
export function canVisitPage(name: PageName): boolean {
|
||||
const permission = pagePermission(name);
|
||||
if (!permission) {
|
||||
return true;
|
||||
}
|
||||
if (permissionInfoUnusable()) {
|
||||
return true;
|
||||
}
|
||||
return canAny(currentIndex(), "view", permission);
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 权限内核的可执行校验(不引测试框架,纯 TS + 断言函数)
|
||||
*
|
||||
* 跑法(PowerShell,在项目根目录):
|
||||
* & "C:\Program Files\nodejs\node.exe" ".\node_modules\esbuild\bin\esbuild" `
|
||||
* src\permission\permission-kernel.spec.ts --bundle --platform=node --format=cjs `
|
||||
* --alias:@=./src --outfile="$env:TEMP\permission-kernel.spec.cjs" --log-level=warning
|
||||
* & "C:\Program Files\nodejs\node.exe" "$env:TEMP\permission-kernel.spec.cjs"
|
||||
*
|
||||
* 为什么要它:判定语义的正确性只能靠"跑一遍"来钉住,尤其 canView 是否检 ViewIndc
|
||||
* 这条——它是本次移植相对旧 WebVue 唯一的行为变更,靠读代码很容易自我说服"应该没问题"。
|
||||
* 项目目前还没接测试运行器,所以先写成"能被 esbuild 打包直接用 node 跑"的形态:
|
||||
* 将来引入 vitest,只需把 check() 换成 expect(),用例本身不用动。
|
||||
*
|
||||
* 约定:所有用例都对照后端 CloudBuilder.Security.Service/Authorization/PermissionContext.cs
|
||||
* 的既有行为写,断言的是"前端与后端一致",不是"前端自己的偏好"。
|
||||
*/
|
||||
import type {
|
||||
DependentRowLike,
|
||||
PermissionInfoLike,
|
||||
PermissionRowLike,
|
||||
} from "./permission-kernel";
|
||||
import {
|
||||
actionsOf,
|
||||
buildPermissionIndex,
|
||||
can,
|
||||
canAny,
|
||||
canOperate,
|
||||
grantedPermissions,
|
||||
} from "./permission-kernel";
|
||||
|
||||
let failed = 0;
|
||||
let total = 0;
|
||||
|
||||
/** 断言:失败不中断,好让一次跑完能看清全部问题 */
|
||||
function check(label: string, actual: unknown, expected: unknown): void {
|
||||
total++;
|
||||
if (actual !== expected) {
|
||||
failed++;
|
||||
// 本项目的 no-console 只放行 warn/error(页面代码里不该打印),
|
||||
// 这是个只在 node 下跑的校验脚本,就沿用放行的那一个
|
||||
console.warn(
|
||||
` FAIL ${label} => expected ${String(expected)}, got ${String(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function row(
|
||||
permission: string,
|
||||
flags: Partial<Record<"amend" | "create" | "delete" | "view", string>>,
|
||||
): PermissionRowLike {
|
||||
return {
|
||||
PermissionName: permission,
|
||||
ViewIndc: flags.view,
|
||||
CreateIndc: flags.create,
|
||||
AmendIndc: flags.amend,
|
||||
DeleteIndc: flags.delete,
|
||||
};
|
||||
}
|
||||
|
||||
function depRow(
|
||||
permission: string,
|
||||
dependent: string,
|
||||
flags: Partial<Record<"amend" | "create" | "delete" | "view", string>>,
|
||||
): DependentRowLike {
|
||||
return { ...row(permission, flags), DependentDetailName: dependent };
|
||||
}
|
||||
|
||||
function info(
|
||||
permissions: PermissionRowLike[] = [],
|
||||
dependents: DependentRowLike[] = [],
|
||||
): PermissionInfoLike {
|
||||
return { Permissions: permissions, PermissionDependents: dependents };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 1. view 必须检 ViewIndc
|
||||
{
|
||||
// view_indc='N' 而 create_indc='Y':后端 CanView 为 false。
|
||||
// 旧前端只看"权限名在不在",会误放行,页面进来后列表接口一片 403。
|
||||
const index = buildPermissionIndex(
|
||||
info([row("ActorEnquiry", { view: "N", create: "Y" })]),
|
||||
);
|
||||
check(
|
||||
"view=N 时 canView 为 false(修复旧前端漏检)",
|
||||
can(index, "view", "ActorEnquiry"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"view=N 时 canNone 仍为 true(只要求挂过权限)",
|
||||
can(index, "none", "ActorEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"create=Y 时 canCreate 为 true",
|
||||
can(index, "create", "ActorEnquiry"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 2. 四种动作各自独立
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([row("DmsFileEnquiry", { view: "Y" })]),
|
||||
);
|
||||
check("只给 view:view 通过", can(index, "view", "DmsFileEnquiry"), true);
|
||||
check(
|
||||
"只给 view:create 不通过",
|
||||
can(index, "create", "DmsFileEnquiry"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"只给 view:amend 不通过",
|
||||
can(index, "amend", "DmsFileEnquiry"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"只给 view:delete 不通过",
|
||||
can(index, "delete", "DmsFileEnquiry"),
|
||||
false,
|
||||
);
|
||||
|
||||
const full = buildPermissionIndex(
|
||||
info([
|
||||
row("DmsFileEnquiry", {
|
||||
view: "Y",
|
||||
create: "Y",
|
||||
amend: "Y",
|
||||
delete: "Y",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
check("四标全 Y:delete 通过", can(full, "delete", "DmsFileEnquiry"), true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 3. 多用户组授权做 OR 合并
|
||||
{
|
||||
// 同一权限名由两个用户组分别授予 view 与 delete,合并后两者都应有
|
||||
const index = buildPermissionIndex(
|
||||
info([
|
||||
row("FamilyGroupEnquiry", { view: "Y" }),
|
||||
row("FamilyGroupEnquiry", { delete: "Y" }),
|
||||
]),
|
||||
);
|
||||
check("多组合并:view 命中", can(index, "view", "FamilyGroupEnquiry"), true);
|
||||
check(
|
||||
"多组合并:delete 命中",
|
||||
can(index, "delete", "FamilyGroupEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"多组合并:amend 仍未命中",
|
||||
can(index, "amend", "FamilyGroupEnquiry"),
|
||||
false,
|
||||
);
|
||||
const flags = actionsOf(index, "FamilyGroupEnquiry");
|
||||
check(
|
||||
"actionsOf 一次取全",
|
||||
`${flags.view}/${flags.create}/${flags.amend}/${flags.delete}`,
|
||||
"true/false/false/true",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 4. 数据级只查 dependents,不回退
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info(
|
||||
[row("PublishPhotoEnquiry", { view: "Y" })],
|
||||
[depRow("PublishPhotoEnquiry", "F1001", { view: "N" })],
|
||||
),
|
||||
);
|
||||
check("功能级命中", can(index, "view", "PublishPhotoEnquiry"), true);
|
||||
// 后端 CanView(permission, dependent) 只查 PermissionDependents;
|
||||
// 前端若"数据级没查到就退回功能级",就比后端宽,会出现前端放行后端 403
|
||||
check(
|
||||
"数据级 N:不回退到功能级",
|
||||
can(index, "view", "PublishPhotoEnquiry", "F1001"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"数据级整条不存在:false",
|
||||
can(index, "view", "PublishPhotoEnquiry", "F9999"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"数据级 Y:命中",
|
||||
can(
|
||||
buildPermissionIndex(
|
||||
info([], [depRow("PublishPhotoEnquiry", "F1", { view: "Y" })]),
|
||||
),
|
||||
"view",
|
||||
"PublishPhotoEnquiry",
|
||||
"F1",
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 5. 严格比较(与后端一致)
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([
|
||||
row("SecurityGroupEnquiry", { view: "y" }),
|
||||
row(" security ", { view: "Y" }),
|
||||
]),
|
||||
);
|
||||
check(
|
||||
"小写 y 不算 YES(后端按 'Y' 精确比较)",
|
||||
can(index, "view", "SecurityGroupEnquiry"),
|
||||
false,
|
||||
);
|
||||
check("权限名区分大小写", can(index, "view", "securitygroupenquiry"), false);
|
||||
check("权限名不做 trim", can(index, "view", "security"), false);
|
||||
check("原样存在的名字可命中", can(index, "view", " security "), true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6. 档案缺失或损坏
|
||||
{
|
||||
const broken: PermissionInfoLike[] = [
|
||||
{},
|
||||
{ Permissions: null },
|
||||
{ Permissions: undefined, PermissionDependents: undefined },
|
||||
{ Permissions: [] },
|
||||
{ Permissions: [null as unknown as PermissionRowLike] },
|
||||
{ Permissions: [{ PermissionName: "" } as PermissionRowLike] },
|
||||
{
|
||||
PermissionDependents: [
|
||||
{ PermissionName: "A", DependentDetailName: "" } as DependentRowLike,
|
||||
],
|
||||
},
|
||||
];
|
||||
broken.forEach((item, i) => {
|
||||
const index = buildPermissionIndex(item);
|
||||
check(
|
||||
`损坏档案[${i}]:不抛异常且判定为 false`,
|
||||
can(index, "view", "A"),
|
||||
false,
|
||||
);
|
||||
check(`损坏档案[${i}]:none 也为 false`, can(index, "none", "A"), false);
|
||||
});
|
||||
check(
|
||||
"传 null 得到空索引",
|
||||
can(buildPermissionIndex(null), "none", "A"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"不传参得到空索引",
|
||||
grantedPermissions(buildPermissionIndex(), "none").length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 7. canAny 批量判定
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([row("PreviewAudio", { view: "Y" })]),
|
||||
);
|
||||
check("数组任一命中", canAny(index, "view", ["Nope", "PreviewAudio"]), true);
|
||||
check("数组全不命中", canAny(index, "view", ["Nope", "Nothing"]), false);
|
||||
check("空数组为 false(而非 undefined)", canAny(index, "view", []), false);
|
||||
check(
|
||||
"数组里的空串被跳过",
|
||||
canAny(index, "view", ["", "PreviewAudio"]),
|
||||
true,
|
||||
);
|
||||
check("单串仍可用", canAny(index, "view", "PreviewAudio"), true);
|
||||
check(
|
||||
"批量判定带 dependent",
|
||||
canAny(index, "view", ["PreviewAudio"], "F1"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 8. 未登记动作与未知权限
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([row("ActorEnquiry", { view: "Y" })]),
|
||||
);
|
||||
check("未授予的权限名:false", can(index, "view", "NeverGranted"), false);
|
||||
const flags = actionsOf(index, "NeverGranted");
|
||||
check(
|
||||
"未授予权限的 actionsOf 返回全 false 而非 undefined",
|
||||
`${flags.none}/${flags.view}`,
|
||||
"false/false",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 9. 已授权清单
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info([
|
||||
row("A_Enquiry", { view: "Y" }),
|
||||
row("B_Enquiry", { view: "N", create: "Y" }),
|
||||
row("C_Enquiry", { view: "Y", delete: "Y" }),
|
||||
]),
|
||||
);
|
||||
check(
|
||||
"none 清单含全部挂过的名字",
|
||||
grantedPermissions(index, "none").join(","),
|
||||
"A_Enquiry,B_Enquiry,C_Enquiry",
|
||||
);
|
||||
check(
|
||||
"view 清单只含 Y",
|
||||
grantedPermissions(index, "view").join(","),
|
||||
"A_Enquiry,C_Enquiry",
|
||||
);
|
||||
check(
|
||||
"create 清单",
|
||||
grantedPermissions(index, "create").join(","),
|
||||
"B_Enquiry",
|
||||
);
|
||||
check(
|
||||
"delete 清单",
|
||||
grantedPermissions(index, "delete").join(","),
|
||||
"C_Enquiry",
|
||||
);
|
||||
check("amend 清单为空", grantedPermissions(index, "amend").length, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 10. 操作型判定(对齐后端 PermissionHandler 的 switch)
|
||||
{
|
||||
const index = buildPermissionIndex(
|
||||
info(
|
||||
[
|
||||
row("PublishPhotoEnquiry", {
|
||||
view: "Y",
|
||||
create: "Y",
|
||||
amend: "Y",
|
||||
delete: "Y",
|
||||
}),
|
||||
],
|
||||
// Submit/Approval 这类操作在后端是"数据级授权行",靠 DependentDetailName 命中
|
||||
[depRow("PublishPhotoEnquiry", "Submit", { view: "N" })],
|
||||
),
|
||||
);
|
||||
check("Find -> view", canOperate(index, "Find", "PublishPhotoEnquiry"), true);
|
||||
check(
|
||||
"Print -> view",
|
||||
canOperate(index, "Print", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Insert -> create",
|
||||
canOperate(index, "Insert", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Update -> amend",
|
||||
canOperate(index, "Update", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Delete -> delete",
|
||||
canOperate(index, "Delete", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"None 只要求挂过",
|
||||
canOperate(index, "None", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Submit 走数据级,命中",
|
||||
canOperate(index, "Submit", "PublishPhotoEnquiry"),
|
||||
true,
|
||||
);
|
||||
check(
|
||||
"Approval 未挂 → false(即使四个 Indc 全 Y)",
|
||||
canOperate(index, "Approval", "PublishPhotoEnquiry"),
|
||||
false,
|
||||
);
|
||||
|
||||
// view_indc='N' 时 Find 必须为 false:这条正是旧前端会误判成 true 的场景
|
||||
const noView = buildPermissionIndex(
|
||||
info([row("ActorEnquiry", { view: "N", create: "Y" })]),
|
||||
);
|
||||
check(
|
||||
"view=N 时 Find 为 false",
|
||||
canOperate(noView, "Find", "ActorEnquiry"),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"view=N 时 Insert 仍为 true",
|
||||
canOperate(noView, "Insert", "ActorEnquiry"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
console.warn(
|
||||
failed === 0
|
||||
? `permission-kernel: ${total} assertions passed`
|
||||
: `permission-kernel: ${failed}/${total} assertions FAILED`,
|
||||
);
|
||||
if (failed > 0) {
|
||||
// 让非零退出可被脚本捕获,避免"跑了但没人看输出"
|
||||
throw new Error("permission-kernel assertions failed");
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 权限判定内核:纯函数 + 预建索引,不碰 uni / pinia / 请求层
|
||||
*
|
||||
* 语义以服务端为准,逐条对齐 CloudBuilder.Security.Service/Authorization/PermissionContext.cs:
|
||||
* - view / create / amend / delete 各自要求对应的 Indc 为 'Y';
|
||||
* - none 只要求权限名挂过(后端 CanNone 同款,用于"有授权行就允许,具体动作交给接口判");
|
||||
* - 传 dependentId 时只在 PermissionDependents 里找,不回退到 Permissions——回退等于比后端宽;
|
||||
* - 不做 '*' 通配、不做大小写宽松匹配:后端是精确字符串比较,前端放宽只会带来
|
||||
* "前端放行、后端 403" 的错觉,那正是本层要消除的东西。
|
||||
*
|
||||
* 与旧 WebVue 的一处行为差异(有意的修复):
|
||||
* 旧 hooks/PermissionContext.ts 的 canView 只判断权限名存不存在,漏检 ViewIndc。
|
||||
* 于是 view_indc='N'、create_indc='Y' 的授权行会让用户进得来页面,
|
||||
* 但页面的列表接口走 Find -> 后端 CanView -> 403,表现为"打开就一片红"。
|
||||
* 这里按后端补齐后,进不来就直接跳 403 页,原因写在页面上。
|
||||
*
|
||||
* 之所以做成纯内核:判定逻辑要能被脚本直接跑起来验证(不依赖设备、不依赖后端),
|
||||
* 也方便日后单测;pinia/uni 的取值与响应式都留在 permission-context.ts 那层。
|
||||
*/
|
||||
import { YesNoPolicy } from "@/data/custom/yesNoPolicy";
|
||||
|
||||
/** 四种可判定的动作,none 表示"不细分动作,只要求有授权行" */
|
||||
export type PermissionAction = "none" | "view" | "create" | "amend" | "delete";
|
||||
|
||||
/**
|
||||
* 权限名入参:单个或数组(数组为 OR,任一命中即通过)。
|
||||
* 放在内核里是因为 routes.ts 的 PAGE_PERMISSIONS 也要用同一份定义,
|
||||
* 而内核不 import 任何模块,不会形成 routes <-> permission 的循环依赖。
|
||||
*/
|
||||
export type PermissionNames = string | string[];
|
||||
|
||||
/**
|
||||
* 权限行的结构快照:只声明内核要用的字段,不 import 生成的实体类。
|
||||
* 生成器(data/auto/*)改字段名时不该牵连判定逻辑,只要后端返回的 JSON 形状不变就照样工作。
|
||||
*/
|
||||
export interface PermissionRowLike {
|
||||
PermissionName: string;
|
||||
ViewIndc?: string;
|
||||
CreateIndc?: string;
|
||||
AmendIndc?: string;
|
||||
DeleteIndc?: string;
|
||||
}
|
||||
|
||||
/** 数据级(dependent)权限行:比功能级多一个 DependentDetailName,如"某个家族/某个机构" */
|
||||
export interface DependentRowLike extends PermissionRowLike {
|
||||
DependentDetailName: string;
|
||||
}
|
||||
|
||||
/** 内核需要的登录档案权限信息子集,与 SecurityUserPermissionInfo 结构兼容 */
|
||||
export interface PermissionInfoLike {
|
||||
// 功能级授权行;DependentRowLike 是其超集,故生成实体可直接传入
|
||||
Permissions?: PermissionRowLike[] | null;
|
||||
// 数据级授权行
|
||||
PermissionDependents?: DependentRowLike[] | null;
|
||||
}
|
||||
|
||||
/** 某个权限名上四种动作的合并结果(多组授权会 OR) */
|
||||
export interface ActionFlags {
|
||||
none: boolean;
|
||||
view: boolean;
|
||||
create: boolean;
|
||||
amend: boolean;
|
||||
delete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预建索引:把旧实现"每次判定都 Array.some 线性扫一遍"换成一次构建、多次 O(1) 查询。
|
||||
* 旧版一个页面要调 canView/canCreate/canAmend/canDelete 四次,扫四遍;
|
||||
* 相册页权限行可能上百,加上小程序 setData 频率敏感,索引这层不亏。
|
||||
*/
|
||||
export interface PermissionIndex {
|
||||
/** key = PermissionName */
|
||||
byName: Map<string, ActionFlags>;
|
||||
/** key = dependentKey(permission, dependent) */
|
||||
byDependent: Map<string, ActionFlags>;
|
||||
}
|
||||
|
||||
/** 空索引:登录档案缺失时用,配合 context 层的"清单为空即放行"策略 */
|
||||
const EMPTY_INDEX: PermissionIndex = {
|
||||
byName: new Map(),
|
||||
byDependent: new Map(),
|
||||
};
|
||||
|
||||
/** 数据级权限的复合键;用 \u0000 分隔,权限名与明细名都不可能出现控制字符 */
|
||||
export function dependentKey(permission: string, dependent: string): string {
|
||||
return `${permission}\u0000${dependent}`;
|
||||
}
|
||||
|
||||
function emptyFlags(): ActionFlags {
|
||||
return {
|
||||
none: false,
|
||||
view: false,
|
||||
create: false,
|
||||
amend: false,
|
||||
delete: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isYes(value?: string): boolean {
|
||||
return value === YesNoPolicy.YES;
|
||||
}
|
||||
|
||||
/** 同一权限名可能被多个用户组授予,合并成 OR:任一组给了某个动作就算有 */
|
||||
function merge(
|
||||
target: Map<string, ActionFlags>,
|
||||
key: string,
|
||||
row: PermissionRowLike,
|
||||
): void {
|
||||
let flags = target.get(key);
|
||||
if (!flags) {
|
||||
flags = emptyFlags();
|
||||
target.set(key, flags);
|
||||
}
|
||||
flags.none = true;
|
||||
flags.view ||= isYes(row.ViewIndc);
|
||||
flags.create ||= isYes(row.CreateIndc);
|
||||
flags.amend ||= isYes(row.AmendIndc);
|
||||
flags.delete ||= isYes(row.DeleteIndc);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建索引。传 null/undefined/空清单会得到空索引,不抛异常——
|
||||
* 登录档案是从本地缓存水合的,损坏或半截是正常状态,判定层不该让页面崩。
|
||||
*/
|
||||
export function buildPermissionIndex(
|
||||
info?: PermissionInfoLike | null,
|
||||
): PermissionIndex {
|
||||
if (!info) {
|
||||
return EMPTY_INDEX;
|
||||
}
|
||||
const byName = new Map<string, ActionFlags>();
|
||||
const byDependent = new Map<string, ActionFlags>();
|
||||
for (const row of info.Permissions ?? []) {
|
||||
if (row?.PermissionName) {
|
||||
merge(byName, row.PermissionName, row);
|
||||
}
|
||||
}
|
||||
for (const row of info.PermissionDependents ?? []) {
|
||||
if (row?.PermissionName && row.DependentDetailName) {
|
||||
merge(
|
||||
byDependent,
|
||||
dependentKey(row.PermissionName, row.DependentDetailName),
|
||||
row,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { byName, byDependent };
|
||||
}
|
||||
|
||||
function lookup(
|
||||
index: PermissionIndex,
|
||||
permission: string,
|
||||
dependent?: string,
|
||||
): ActionFlags | undefined {
|
||||
if (dependent) {
|
||||
return index.byDependent.get(dependentKey(permission, dependent));
|
||||
}
|
||||
return index.byName.get(permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条判定。
|
||||
* @param index 预建索引
|
||||
* @param action 要判定的动作
|
||||
* @param permission 权限名,须与后端 security_function.permission_name 逐字一致
|
||||
* @param dependent 数据级授权明细名;给了它就只查 dependent 集合,与后端一致
|
||||
*/
|
||||
export function can(
|
||||
index: PermissionIndex,
|
||||
action: PermissionAction,
|
||||
permission: string,
|
||||
dependent?: string,
|
||||
): boolean {
|
||||
const flags = lookup(index, permission, dependent);
|
||||
if (!flags) {
|
||||
return false;
|
||||
}
|
||||
return action === "none" ? flags.none : flags[action];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量判定:任一权限名通过即通过(OR)。
|
||||
* 一个页面被多个功能权限共享时用得上,例如相册浏览页同时挂在
|
||||
* PublishPhotoEnquiry 与 FamilyGroupShare 之下。
|
||||
*/
|
||||
export function canAny(
|
||||
index: PermissionIndex,
|
||||
action: PermissionAction,
|
||||
permissions: PermissionNames,
|
||||
dependent?: string,
|
||||
): boolean {
|
||||
const list = Array.isArray(permissions) ? permissions : [permissions];
|
||||
// 刻意不写成 list.some(name => ...):单参数箭头的括号,prettier 要加、
|
||||
// ESLint 要去,改回去就来回拉锯;for-of 在两个工具下都稳定
|
||||
for (const name of list) {
|
||||
if (name && can(index, action, name, dependent)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后端 [PermissionOperate(name, Operate.X)] 的枚举值,逐字照抄
|
||||
* CloudBuilder.Core/Authorization/PermissionOperateAttribute.cs 的 Operate。
|
||||
* 用 as const 数组而不是多行字面量联合:联合类型的换行位置 prettier 与
|
||||
* operator-linebreak 各执一词,只有这种写法在两个工具下都不被改写
|
||||
*/
|
||||
export const PERMISSION_OPERATES = [
|
||||
"None",
|
||||
"Find",
|
||||
"Print",
|
||||
"Insert",
|
||||
"Update",
|
||||
"Delete",
|
||||
"Submit",
|
||||
"Approval",
|
||||
] as const;
|
||||
|
||||
/** 后端 Operate 枚举的字符串取值 */
|
||||
export type PermissionOperate = (typeof PERMISSION_OPERATES)[number];
|
||||
|
||||
/**
|
||||
* 按后端的鉴权口径判一个操作:与 PermissionHandler.HandleRequirementAsync 的 switch 一一对应。
|
||||
* Find/Print -> view,Insert -> create,Update -> amend,Delete -> delete;
|
||||
* 其余(Submit/Approval)走 default 分支——后端把 operate 字符串当成 DependentDetailName
|
||||
* 去数据级授权里查"有没有挂过这个操作",所以这里同样是 none + dependent。
|
||||
*
|
||||
* 用意:前端按钮的可用性判定和接口能不能调,用的是同一份映射,不会出现
|
||||
* "提交按钮亮着、请求过去被 403"。新增操作类型时只改这一处。
|
||||
*/
|
||||
export function canOperate(
|
||||
index: PermissionIndex,
|
||||
operate: PermissionOperate,
|
||||
permission: string,
|
||||
): boolean {
|
||||
switch (operate) {
|
||||
case "None": {
|
||||
return can(index, "none", permission);
|
||||
}
|
||||
case "Find":
|
||||
case "Print": {
|
||||
return can(index, "view", permission);
|
||||
}
|
||||
case "Insert": {
|
||||
return can(index, "create", permission);
|
||||
}
|
||||
case "Update": {
|
||||
return can(index, "amend", permission);
|
||||
}
|
||||
case "Delete": {
|
||||
return can(index, "delete", permission);
|
||||
}
|
||||
default: {
|
||||
return can(index, "none", permission, operate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 取某权限名(或某条数据级授权)的全部动作,供 UI 一次性渲染按钮可用性 */
|
||||
export function actionsOf(
|
||||
index: PermissionIndex,
|
||||
permission: string,
|
||||
dependent?: string,
|
||||
): ActionFlags {
|
||||
return lookup(index, permission, dependent) ?? emptyFlags();
|
||||
}
|
||||
|
||||
/** 已授权的动作清单,调试面板与"我到底有什么权限"排查用 */
|
||||
export function grantedPermissions(
|
||||
index: PermissionIndex,
|
||||
action: PermissionAction,
|
||||
): string[] {
|
||||
const names: string[] = [];
|
||||
index.byName.forEach((flags, name) => {
|
||||
if (action === "none" ? flags.none : flags[action]) {
|
||||
names.push(name);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
}
|
||||
@@ -1,11 +1,46 @@
|
||||
/**
|
||||
* 站点配置的单一来源。
|
||||
*
|
||||
* 分层规则:项目根 .env / .env.development / .env.production 提供可覆盖值,
|
||||
* 本文件的字面量是 fallback——变量缺失、空串或纯空白时用它兜底,
|
||||
* 所以删掉 .env 也照样能构建出可用产物。取值口径见 src/md/environment.md。
|
||||
*
|
||||
* 只有"换环境要动"的后端根地址进 .env,品牌文案与路由常量留在本文件。
|
||||
* 读取一律写成 import.meta.env.VITE_XXX 的字面量形式:小程序 / App 端由 uni 编译器
|
||||
* 做静态替换,`import.meta.env[key]` 这类动态取键在真机上取不到值。
|
||||
*/
|
||||
|
||||
/** 取环境变量:非字符串、空串、纯空白都算未配置,回落 fallback */
|
||||
function env(value: string | undefined, fallback: string): string {
|
||||
const text = String(value ?? "").trim();
|
||||
return text.length > 0 ? text : fallback;
|
||||
}
|
||||
|
||||
/** 版权文案单独提成常量:写成类成员的字面量会超 printWidth,触发格式化器与 ESLint 换行位置对撞 */
|
||||
const COPYRIGHT = "Copyright © 2024-2025 Cloud Builder All Rights Reserved.";
|
||||
|
||||
export class WebSitePolicy {
|
||||
static readonly Title: string = "有家相册";
|
||||
static readonly Copy: string
|
||||
= "Copyright © 2024-2025 Cloud Builder All Rights Reserved.";
|
||||
static readonly Copy: string = COPYRIGHT;
|
||||
|
||||
/** 发布构建(mode=production)使用的后端根地址 */
|
||||
static readonly WebApiRoot: string = env(
|
||||
import.meta.env.VITE_WEB_API_ROOT,
|
||||
"https://uat.youjiala.com",
|
||||
);
|
||||
|
||||
/** App 端开发构建使用的后端根地址:真机与模拟器连不上宿主机 localhost */
|
||||
static readonly WebApiRootDevApp: string = env(
|
||||
import.meta.env.VITE_WEB_API_ROOT_DEV_APP,
|
||||
"https://uat.youjiala.com",
|
||||
);
|
||||
|
||||
/** 开发构建使用的后端根地址:H5 默认连宿主机本地后端 */
|
||||
static readonly WebApiRootDev: string = env(
|
||||
import.meta.env.VITE_WEB_API_ROOT_DEV,
|
||||
"http://localhost:5235",
|
||||
);
|
||||
|
||||
static readonly WebApiRoot: string = "https://uat.youjiala.com";
|
||||
static readonly WebApiRootDevApp: string = "https://uat.youjiala.com";
|
||||
static readonly WebApiRootDev: string = "http://localhost:5235";
|
||||
static readonly WebSite: string = "http://192.168.195.68";
|
||||
static readonly WebProxyUrl: string = "";
|
||||
static readonly Dashboard: string = "/dashboard";
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
import type { ErrorPageName, PageName } from "@/router/routes";
|
||||
import { getJwtInfo } from "@/common/http.interceptor";
|
||||
import { canVisitPage } from "@/permission/permission-context";
|
||||
import {
|
||||
errorPageUrl,
|
||||
isAuthFree,
|
||||
@@ -32,7 +33,7 @@ function isLoggedIn(): boolean {
|
||||
* 判定目标地址该被拦成哪个错误页;返回 null 表示放行
|
||||
*
|
||||
* 顺序即语义:地址不存在(404) -> 没登录(401) -> 没权限(403)
|
||||
* 403 这层要等 PAGE_PERMISSIONS 与 PermissionContext 移植后接在这里
|
||||
* 403 必须排在 401 之后:未登录时权限档案多半也是空的,先给 401 才谈得上"去登录"
|
||||
*/
|
||||
function evaluate(url: string): ErrorPageName | null {
|
||||
const name: PageName | null = resolvePageName(url);
|
||||
@@ -45,6 +46,9 @@ function evaluate(url: string): ErrorPageName | null {
|
||||
if (!isLoggedIn()) {
|
||||
return "error401";
|
||||
}
|
||||
if (!canVisitPage(name)) {
|
||||
return "error403";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* 因此"路径比较"必须用 toRealPath(),只有"跳转用的 URL"才允许用 pageUrl();
|
||||
* - 详细设计与源码证据见 src/md/page-routing.md。
|
||||
*/
|
||||
import type { PermissionNames } from "@/permission/permission-kernel";
|
||||
|
||||
export const PAGE_PATHS = {
|
||||
login: "pages/login/login",
|
||||
dashboard: "pages/dashboard/dashboard",
|
||||
@@ -43,9 +45,11 @@ export const AUTH_FREE_PAGES: PageName[] = [
|
||||
|
||||
/**
|
||||
* 页面所需权限名(权限守卫用,格式同旧系统 meta.permission)。
|
||||
* 暂未接入:等菜单/功能权限体系移植时逐条填进来,缺省表示不校验
|
||||
* 取值必须是后端 security_function.permission_name 里存在的功能名(如 ActorEnquiry、
|
||||
* DmsFileEnquiry),写错等于该页永远进不去;登记流程与反查方法见 src/md/permission.md。
|
||||
* 数组表示任一命中即放行。缺省(未登记)表示该页不做权限校验,登录校验照旧。
|
||||
*/
|
||||
export const PAGE_PERMISSIONS: Partial<Record<PageName, string>> = {};
|
||||
export const PAGE_PERMISSIONS: Partial<Record<PageName, PermissionNames>> = {};
|
||||
|
||||
/** 带前导斜杠的真实路径,供 uni.* API 之外的路径比较、日志使用 */
|
||||
export function toRealPath(name: PageName): string {
|
||||
@@ -97,6 +101,11 @@ export function normalizePath(url: string): string {
|
||||
* 认不出即返回 null,调用方据此判定"地址不存在"
|
||||
*/
|
||||
export function resolvePageName(url: string): PageName | null {
|
||||
// 空地址不算页面:normalizePath 会把 "" 归一成 "/",于是 "" 曾被认成入口页放行,
|
||||
// 登录页缺 ?redirect= 时就顺着这条判断把落地地址覆盖成了空串,reLaunch 直接 fail
|
||||
if (!String(url || "").trim()) {
|
||||
return null;
|
||||
}
|
||||
const path = normalizePath(url);
|
||||
const names = Object.keys(PAGE_PATHS) as PageName[];
|
||||
// "/" 是 H5 入口页(登录页)的路由 path,不对应任何字面配置
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* 项目自有环境变量的类型声明(值来自项目根的 .env / .env.development / .env.production)
|
||||
*
|
||||
* 为什么要显式声明:vite/client 里的 ImportMetaEnv 带 `[key: string]: any` 索引签名,
|
||||
* 不写这份声明时 `import.meta.env.VITE_XXX` 拼错也是 any、编译器一声不响;
|
||||
* 补上字面量属性后,写错键名会直接报"属性不存在"。
|
||||
*
|
||||
* 纪律:只能通过 `import.meta.env.VITE_XXX` 这种**字面量**形式读取。
|
||||
* 动态取值(`import.meta.env[key]`)在 H5 里能工作,但小程序 / App 端由 uni 的编译器
|
||||
* 做静态替换,动态键不会被替换掉,真机上会拿到 undefined。
|
||||
*
|
||||
* 分层规则与"缺失时回落到 WebSitePolicy 常量"的口径见 src/md/environment.md。
|
||||
*/
|
||||
interface ImportMetaEnv {
|
||||
/** 发布构建(mode=production)使用的后端根地址 */
|
||||
readonly VITE_WEB_API_ROOT: string;
|
||||
/** 开发构建使用的后端根地址:H5 默认连宿主机本地后端 */
|
||||
readonly VITE_WEB_API_ROOT_DEV: string;
|
||||
/** App 端开发构建使用的后端根地址:真机/模拟器连不上宿主机 localhost */
|
||||
readonly VITE_WEB_API_ROOT_DEV_APP: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in new issue
Block a user