ow
This commit is contained in:
1 parent
6a8258ac4b
commit
a7b25d49c4
29 files changed
+1960
-649
No files matched your search
@@ -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