Commit 72f883d9 authored by Andy-bubu's avatar Andy-bubu

fix: align application and detail permissions

parent fd8348b3
......@@ -140,8 +140,9 @@ CSS,并用显式 `@source` 仅扫描入口、组件、页面和 wrapper;`src
```text
UserContext
-> user.applications[name = audit_project_manager]
-> Grid viewAuth / field auth(Detail View 只负责编排与 shortTitle)
-> user.applications[name = audit_project_manager_new]
-> Grid viewAuth
-> active Detail auth/disableColumns(未配置时整体回退 active Grid)
Navbar notification badge
-> platform notice APIs -> /notifications -> base-aware internal navigation
......@@ -154,9 +155,11 @@ Editable file field
```
Subject/View 资源必须先加载并完成 Grid View 授权过滤;只有存在可访问 View 才允许查询记录。
`RecordDetailDrawer` 接收激活 Grid View 作为唯一 `authorizationView`,激活 Detail View 只决定字段
布局、条件和记录标题是否显示。通知与材料服务只封装既有接口参数,不拥有 Shell、记录更新或
业务状态流。
`RecordDetailDrawer` 接收激活 Grid View 作为 `authorizationView`,并通过
`GET /subject/:subjectName/view/:viewName` 获取完整 Detail 配置。当前 Detail 有任一有效
`auth/disableColumns` 时使用 Detail 整套权限;完全未配置时才整体回退 Grid。Detail `columns`
只决定展示范围和顺序,权限来源不会合并。通知与材料服务只封装既有接口参数,不拥有 Shell、
记录更新或业务状态流。
通知路由解析以 `ROUTE_TITLES` 的真实注册路径为本应用站内白名单,并只剥离
`/audit_project_manager_new` 自身 base;不能把其它应用的裸路径交给当前 Umi Router。
......
......@@ -97,7 +97,8 @@
- [x] Grid `shortTitle` 按激活 View 生效,title 仍参与搜索且业务入口保留
- [x] Detail `shortTitle` 随激活 Detail View 即时更新
- [x] 权限只读取 `audit_project_manager` 应用项,缺省拒绝且管理旁路正确
- [x] 权限只读取稳定应用 `audit_project_manager_new`,不合并旧入口或其它应用授权
- [x] Detail 完整配置优先,未配置时整体回退 Grid,权限不合并且编辑按钮只检查当前 Detail
- [x] 无可访问 Grid View 时不查询记录、不建 fallback、不显示新增/批量/行操作
- [x] 只有一个可访问 Grid View 时隐藏 Tabs 轨道
- [x] `/notifications`、Navbar Badge、消息/待办、全部/未读和动态操作完成
......
......@@ -97,8 +97,11 @@ Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行 `
- `settings.shortTitle === true` 只隐藏当前 View 的 `title` 展示;title 数据、关键词搜索、排序、
保存与详情入口不得丢失。Detail 标题显示只跟随当前激活 Detail View。
- View/字段权限只能读取正式应用 `audit_project_manager` 在 `user.applications[]` 中的授权项。
- View/字段权限只能读取当前稳定应用 `audit_project_manager_new` 在 `user.applications[]` 中的授权项。
系统管理员及该应用“管理”角色可旁路;普通用户缺失配置时默认拒绝,`disableColumns` 不旁路。
- Grid `viewAuth` 只控制 Tab 可见性。详情字段权限优先使用当前完整 Detail View 的整套
`auth/disableColumns`;两项都没有有效配置或不存在实际 Detail 时,整体回退当前 Grid,禁止
合并或逐字段回退。编辑按钮只检查当前 Detail 展示字段。
- Navbar 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,位于主题按钮左侧;通知页面
使用原平台接口,正文按纯文本呈现,站内路径剥离 `/audit_project_manager_new` 后交给 Umi。
- editable file 字段统一提供“材料中心 / 本地上传”。选择材料只写文件快照;本地文件先上传到
......
......@@ -7,8 +7,8 @@
- 分支:`codex/ui-ux-xinyuan-migration`
- 唯一实现基准:`problem_rectification`
- 应用基路径:`/audit_project_manager_new/`
- 本轮启动命令:`npm run dev -- --port 8000`
- 目标验收地址:`http://127.0.0.1:8000/audit_project_manager_new/`
- 本轮启动命令:`npm run dev -- --port 8003`(Umi 检测端口占用后选择 `8007`)
- 目标验收地址:`http://127.0.0.1:8007/audit_project_manager_new/`
- 基准验收地址:`http://127.0.0.1:8001/problem_rectification_new/`
迁移前已启动未修改项目并记录业务基线。原登录页为蓝色营销式左右分栏,Shell、表格、
......@@ -263,18 +263,19 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
- 功能白名单判定:本应用为“审计项目管理”,属于完整功能应用,实施 Grid/Detail
`shortTitle`、视图权限、消息通知、文件与审计材料中心五项功能。
- 运行标识:Subject 为 `audit_project`,正式应用授权名与材料 `source_app` 为
`audit_project_manager`;Umi 路由 base 为 `/audit_project_manager_new/`。站点配置同时存在
演示入口 `audit_project_manager_new`,但它不作为权限或材料来源标识。
- 运行标识:Subject 为 `audit_project`,当前稳定应用标识、应用授权名与材料 `source_app` 均为
`audit_project_manager_new`,Umi 路由 base 由该标识生成。站点配置同时保留旧入口
`audit_project_manager`,但当前应用不得读取或合并旧入口授权。
- 登录样本:Andy 的 `type` 为 `admin`、`applications` 为空,只能验证系统管理员旁路;普通
用户必须仅从 `user.applications[]` 中名称为 `audit_project_manager` 的项目读取 group、role
用户必须仅从 `user.applications[]` 中名称为 `audit_project_manager_new` 的项目读取 group、role
和 permission,禁止合并顶层或其他应用授权。
- View 现状:七个 Grid View 均配置 `shortTitle: true`;“计划”额外配置 group
`viewAuth` 与字段 `auth`。页面此前通过常量无条件隐藏 `title`,搜索也随之丢失 title;
View 权限缺省允许、无权时创建 fallback,并在鉴权前查询数据,均不符合基准。
- Detail 现状:详情视图未统一配置 `shortTitle`;抽屉始终显示记录标题,字段权限同时受 Detail
View 影响。目标行为应随当前激活 Detail View 即时切换标题显示,但字段权限只读取激活 Grid
View,`disableColumns` 永远只读。
- Detail 原始实现只读取 View 摘要,并合并 Detail 与 Grid 字段权限,编辑按钮还会遍历其它 Detail。
现已逐个读取完整 View 配置;当前 Detail 存在有效 `auth/disableColumns` 时整套优先,两项都未
配置或不存在实际 Detail 时才整体回退激活 Grid。`disableColumns` 永远只读,编辑按钮只检查
当前 Detail 的展示字段。
- 通知现状:原 `DataService` 已提供 `queryUserNotice`、`readUserNotice`、
`unReadNoticeCount` 和 `exacteActionFunction`,但没有独立页面、路由或 Navbar 入口。
- 文件现状:editable file 字段只执行 `/upload` 并立即写入详情草稿,没有材料中心选择,也没有
......@@ -393,6 +394,27 @@ rg -n 'hp_[A-Za-z0-9]+' . --glob '!node_modules/**' --glob '!dist/**' --glob '!.
返回 200,标题正确,页面级横向溢出均为 0,捕获结果为 `0 errors / 0 warnings`;此前为验证
材料失败重试而故意模拟的 500 日志未计入本次干净结果。
### 2026-08-26 权限归属与 Detail 优先级修复
- 以问题整改提交 `c047ec4` 和 `6763c26` 为代码基准复核。此前目标应用把当前
`/audit_project_manager_new/` 入口的 View 权限与材料来源错误绑定到旧
`audit_project_manager`。现新增稳定 `APPLICATION_NAME = "audit_project_manager_new"`,并由它
统一生成 base、应用切换当前项、授权应用名和材料 `source_app`;旧入口及其它应用的
groups/roles/permissions 不再参与当前应用权限。
- 详情 Hook 此前只使用 `/subject/:subjectName/view` 返回的摘要。现按摘要中的 View name 调用
`GET /subject/:subjectName/view/:viewName` 取得完整配置,并在单个请求失败时仅回退该 View 摘要,
不影响其它 View 加载。
- 当前 Detail 存在任一有效 `auth` 或 `disableColumns` 时使用 Detail 整套字段权限;两项都没有
配置或不存在实际 Detail 时才整体使用激活 Grid。两套权限不合并,Detail 已展示但未配置 auth
的字段对普通用户只读;`hasEditableFields` 仅检查当前 Detail 展示字段,切换 Tab 后立即更新。
- `$playwright-cli` 专项 fixture 只存在浏览器会话,未请求真实写接口。普通 Detail 授权用户在
自带权限 Detail 可编辑、切到无配置 Detail 后因缺少 Grid 字段授权变为只读;普通 Grid 授权
用户在前者不能逐字段借用 Grid,切到后者后才可编辑。完整 Detail endpoint 均实际发起请求。
- 只有旧 `audit_project_manager` 与其它应用“管理”角色的用户显示“暂无可访问的视图”,
`/record/query` 为 0;当前 `audit_project_manager_new` 的“管理”角色和系统管理员旁路正常。
管理员编辑态下普通授权字段显示输入框,`disableColumns` 字段仍为只读文本。页面横向溢出为 0,
控制台为 `0 errors / 0 warnings`。
### 2026-08-24 DataGrid 长文本边界复核
- 全局三个 HeroUI Pro DataGrid 消费入口均复用 `DataTable`:主业务列表、动态表单数据表和
......
import '@/tailwind.css';
import { defineApp } from 'umi';
import { APPLICATION_BASE_PATH, THEME_STORAGE_KEY } from '@/config/application';
import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
THEME_STORAGE_KEY,
} from '@/config/application';
if (typeof document !== 'undefined') {
const storedTheme = window.localStorage?.getItem?.(THEME_STORAGE_KEY);
......@@ -28,7 +32,7 @@ if (
export async function getInitialState() {
return {
name: 'audit_project_manager',
name: APPLICATION_NAME,
};
}
......
export const APPLICATION_DISPLAY_NAME = '审计项目管理';
export const APPLICATION_BASE_PATH = '/audit_project_manager_new/';
export const AUTHORIZATION_APPLICATION_NAME = 'audit_project_manager';
export const SOURCE_APPLICATION_NAME = 'audit_project_manager';
export const APPLICATION_NAME = 'audit_project_manager_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const SOURCE_APPLICATION_NAME = APPLICATION_NAME;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
......
......@@ -92,9 +92,9 @@ export default function SubjectViewContent({
mode === 'view' &&
!minimal &&
(fieldType === 'form' || isReferenceFieldType(fieldType));
const fieldDisabled =
isFieldDisabled?.(field.field, view) ||
isFieldDisabledByView(field.field, view);
const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view)
: isFieldDisabledByView(field.field, view);
const fieldContent = (
<>
......
import { useEffect, useRef, useState } from 'react';
import {
getSubjectView,
getSubjectViewDetail,
loadRecord,
loadSubject,
updateRecord,
......@@ -97,9 +98,27 @@ export function useSubjectRecord(
try {
const record = await loadRecord(recordName);
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const [subjectData, viewsData] = resolvedSubjectName
const [subjectData, viewSummaries] = resolvedSubjectName
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
: [null, []];
const viewsData = await Promise.all(
(Array.isArray(viewSummaries) ? viewSummaries : []).map(async (view) => {
if (!view?.name || !resolvedSubjectName) {
return view;
}
try {
const detail = await getSubjectViewDetail(resolvedSubjectName, view.name);
return {
...view,
...detail,
settings: detail?.settings || view?.settings || {},
};
} catch (error) {
return view;
}
}),
);
if (!cancelled && requestIdRef.current === requestId) {
setRemoteRecord(record);
......
......@@ -18,7 +18,7 @@ import {
buildViewColumns,
checkViewCondition,
getFieldValue,
isFieldDisabledByView,
hasFieldAuthorizationConfiguration,
setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
......@@ -265,10 +265,16 @@ const RecordDetailDrawer = (props, ref) => {
),
[disabledFields],
);
const isDrawerFieldDisabled = (fieldPath, currentView) =>
const isDrawerFieldDisabled = (fieldPath, currentView) => {
const effectiveAuthorizationView = hasFieldAuthorizationConfiguration(currentView)
? currentView
: authorizationView || currentView;
return (
disabledFieldSet.has(fieldPath) ||
isFieldDisabled(fieldPath, currentView) ||
isFieldDisabledByView(fieldPath, authorizationView);
isFieldDisabled(fieldPath, effectiveAuthorizationView)
);
};
const refreshRecord = ({ selectLastTab = false } = {}) => {
if (selectLastTab || autoSwitchToLastTab) {
......@@ -319,12 +325,10 @@ const RecordDetailDrawer = (props, ref) => {
!error &&
!effectiveReadonly &&
!minimalContent &&
enabledDetailViews.some((view) =>
buildViewColumns(view, subject).some(
buildViewColumns(activeView, subject).some(
(field) =>
!NON_EDITABLE_FIELD_TYPES.has(field.fieldDef?.type) &&
!isDrawerFieldDisabled(field.field, view),
),
!isDrawerFieldDisabled(field.field, activeView),
);
const hasDirtyFields = Object.keys(dirtyFields).length > 0;
const hasValidationErrors = Object.values(validationErrors).some(Boolean);
......
......@@ -2,6 +2,7 @@ import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME,
APPLICATION_NAME,
} from '@/config/application';
import { getApplications } from '@/services/DataService';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
......@@ -18,7 +19,7 @@ import {
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
const CURRENT_APPLICATION_NAME = APPLICATION_BASE_PATH.replace(/^\/+|\/+$/g, '');
const CURRENT_APPLICATION_NAME = APPLICATION_NAME;
function unwrapApplications(response) {
const candidates = [
......
......@@ -126,12 +126,14 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- 登录页首次打开时调用 `GET /auth/captcha` 获取验证码图片和 token;登录请求在既有账号、
加密密码与 `type=password` 基础上提交 `captcha` 和 `token`。用户可点击验证码刷新;登录
失败时清空验证码输入并重新获取验证码,成功后的 token 保存和 redirect 流程保持不变。
- Grid View 的 `settings.viewAuth` 控制视图可见性,当前激活 Grid View 的 `settings.auth` 控制
详情字段编辑权限,`settings.disableColumns` 对所有用户始终只读。授权只读取
`user.applications[]` 中 `name = "audit_project_manager"` 的应用项;系统管理员、系统账号及该
应用“管理”角色可旁路 View/字段 auth,但不能旁路 `disableColumns`。普通用户缺少配置时默认
拒绝;没有可访问 Grid View 时不查询记录、不创建兜底视图,也不展示新增、批量和行操作;只有
一个可访问 View 时隐藏 Tabs 轨道。
- Grid View 的 `settings.viewAuth` 只控制视图可见性。详情打开时逐个读取完整 Detail 配置;当前
Detail 存在任一有效 `settings.auth` 或 `settings.disableColumns` 时,整套使用 Detail 权限,
两项都未配置或不存在实际 Detail 时才整体回退当前 Grid,禁止合并或逐字段回退。Detail
`columns` 只控制字段展示范围和顺序,编辑按钮只检查当前 Detail 展示字段。授权只读取
`user.applications[]` 中 `name = "audit_project_manager_new"` 的应用项;系统管理员、系统账号及
该应用“管理”角色可旁路 View/字段 auth,但不能旁路 `disableColumns`。普通用户在选定权限来源
中缺少字段 `auth` 时只读;没有可访问 Grid View 时不查询记录、不创建兜底视图,也不展示新增、
批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。
- 历史快照列表只读,不允许新建、复制或删除。
- 批量归档未返回任何历史记录 ID 时按失败处理,并保留原审计项目数据。
......@@ -152,6 +154,6 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
远程搜索;选择后只把 `{name,type,size,url}` 快照写入业务草稿。
- 本地文件先调用 `/upload` 并进入当前详情草稿。取消编辑不创建材料主档;单值替换,多值追加并
按 URL 去重,移除或清空业务附件不删除材料中心主档或底层文件。
- 用户点击详情“保存”后,系统才用 `source_app = "audit_project_manager"`、当前用户、字段快照和
- 用户点击详情“保存”后,系统才用 `source_app = "audit_project_manager_new"`、当前用户、字段快照和
ISO 时间逐个创建 `am_material`。全部建档成功后才调用既有业务记录更新回调;部分失败会在当前
编辑会话保留已建档标记,重试只创建尚未成功的材料,避免重复主档。
......@@ -92,8 +92,8 @@ function intersects(left, right) {
return collectIdentityValues(left).some((item) => rightSet.has(item));
}
function getAuthorizationRule(authRule) {
return ['group', 'role', 'permission', 'username'].includes(authRule?.type)
function getMultidimensionalTableAuthRule(authRule) {
return ['group', 'role', 'permission'].includes(authRule?.type)
? authRule
: '管理';
}
......@@ -413,7 +413,19 @@ export function isViewAccessible(
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
return hasAuthAccess(getAuthorizationRule(view?.settings?.viewAuth), user, applicationName);
return hasAuthAccess(
getMultidimensionalTableAuthRule(view?.settings?.viewAuth),
user,
applicationName,
);
}
export function hasFieldAuthorizationConfiguration(view) {
const settings = view?.settings;
return (
toArray(settings?.auth).some((rule) => normalizeText(rule?.field)) ||
toArray(settings?.disableColumns).some((column) => normalizeText(column?.field))
);
}
export function isFieldDisabledByView(
......@@ -432,7 +444,11 @@ export function isFieldDisabledByView(
}
const fieldAuthRule = toArray(view?.settings?.auth).find((rule) => rule.field === fieldPath);
return !hasAuthAccess(getAuthorizationRule(fieldAuthRule), user, applicationName);
return !hasAuthAccess(
getMultidimensionalTableAuthRule(fieldAuthRule),
user,
applicationName,
);
}
export function recordMatchesRestrictions(record, restrictions = []) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment