Commit 5f6746ea authored by Andy-bubu's avatar Andy-bubu

feat: align cross-app Xinyuan capabilities

parent 4ecd1406
......@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
login: '/login',
resourceStat: '/resource/stat',
integrityParty: '/integrity-party',
notifications: '/notifications',
} as const;
export default defineConfig({
......@@ -47,6 +48,10 @@ export default defineConfig({
path: ROUTE_PATHS.integrityParty,
component: './project_manager_xy/integrity-party/index',
},
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
],
},
],
......
......@@ -26,6 +26,15 @@
- 日期 UI 值在页面/服务边界转换,网络载荷保持 ISO 8601。
- UI 迁移不得改变路由、权限、请求时机、Excel 映射、标题生成、批量处理或错误策略。
## View、通知与材料事务
- `src/config/ApplicationConfig.js` 以 `APPLICATION_NAME = "incorrupt_party_build"` 统一路由、授权和材料来源标识;`src/utils/subjectView.js` 只解析 `user.applications[]` 中该应用项,不合并顶层身份或其它应用权限。
- `integrityPartyService.loadLedgerViewResources` 加载 Subject、View summaries/details 并过滤可访问 Grid View。没有真实可访问 Grid View 时不创建兜底 View、不查询记录,也不显示新增、批量或行操作。
- Detail 存在任一有效 `settings.auth` 或 `settings.disableColumns` 时整套作为字段权限来源;两项都未配置或没有实际 Detail 时整体回退当前 Grid。两套权限不合并、不逐字段回退,选定来源中的 `disableColumns` 对所有用户始终只读。
- Detail `columns` 只决定字段展示范围和顺序,`auth` 独立决定编辑能力;普通用户在选定权限来源中缺少字段 `auth` 时只读。切换 Detail 或 Grid 后展示和字段权限立即重新计算。
- `notifications/notificationService.js` 复用平台 Notice API;页面负责搜索、分页、单条/全部已读、动态 action 与 base-aware 跳转,Shell 只维护 60 秒未读轮询和 Navbar Badge。
- `IntegrityPartyMaterialField` 管理文件快照和待建档列表;`pendingLocalMaterialsRef` 属于一次详情编辑会话。保存时先补建未建档 `am_material`,全部成功后才写业务 Record;失败保留已建档标记供本次重试跳过,关闭弹窗清空会话。
## 依赖来源
- 当前仓库通过官方 `hpsetup` 独立安装 HeroUI Pro,精确版本为 `1.0.0-beta.8`,npm lockfile 解析到官方 registry tarball。
......@@ -37,4 +46,4 @@
已移除活动源码中的 `src/catalyst-ui-kit`、Headless UI ApplicationSwitcher、旧 `FormSelect` DOM API、旧 Table 兼容解析和页容量 Select。业务列表与只读 Excel 导入预览均使用 HeroUI Pro DataGrid。
Native HeroUI 静态架构门禁已通过:`src/xinyuan-portable.css` 与 ECharts 已移除,页面目录无私有 CSS/Less/SCSS;仅保留与确认基准逐字节一致的共享样式层。HeroUI 与 HeroUI Pro 的样式分别通过 `@heroui/styles/css` 和 `@heroui-pro/react/css` 显式加载;包根 `@heroui/styles` 是 JavaScript 样式工具入口,不能替代 CSS 入口。运行态浏览器门禁仍需在浏览器实例可用后完成。
Native HeroUI 静态架构门禁已通过:`src/xinyuan-portable.css` 与 ECharts 已移除,页面目录无私有 CSS/Less/SCSS;仅保留与确认基准逐字节一致的共享样式层。HeroUI 与 HeroUI Pro 的样式分别通过 `@heroui/styles/css` 和 `@heroui-pro/react/css` 显式加载;包根 `@heroui/styles` 是 JavaScript 样式工具入口,不能替代 CSS 入口。2026-08-26 已完成四视口、深浅主题、权限、通知和文件事务的运行态浏览器门禁。
......@@ -40,11 +40,11 @@
- [x] HeroUI / React Aria 依赖树检查
- [x] HeroUI / HeroUI Pro 官方 CSS 入口进入生产构建产物
- [x] Pro CSS 文件和 sidebar/navbar/data-grid/drop-zone/action-bar/empty-state ESM 子路径
- [ ] 1440x900、1280x720、768x1024、390x844
- [ ] 浅色/深色、打开态弹层、选择与 Checkbox、Modal、Sidebar
- [ ] 控制台无 runtime、slot、provider、重复 Context 错误
- [x] 1440x900、1280x720、768x1024、390x844
- [x] 浅色/深色、打开态弹层、选择与 Checkbox、Modal、Sidebar
- [x] 活动页面无 runtime、slot、provider、重复 Context 错误
运行态验收说明:2026-08-19 本地开发服务已启动,但当前执行环境未暴露可连接的应用内浏览器或 Chrome 实例,以上三项浏览器门禁尚未执行,不能以静态检查替代。
运行态验收说明:2026-08-26 使用 Playwright CLI 隔离会话完成四视口与深浅主题检查;1440x900 浅色、1280x720/768x1024/390x844 深色台账均已截图,390px `scrollWidth === 390`。通知页额外完成 1440/390 浅色截图。既有 HeroUI Pro Mobile Sidebar 隐藏 Dialog title 告警仍存在,但活动页面无运行时错误,未发现 slot、provider 或重复 Context 错误。
## 2026-08-26 应用切换专项验收
......@@ -55,3 +55,24 @@
- [x] Escape、关闭按钮、焦点约束、dialog accessible name 与 `aria-current` 通过。
- [x] 应用切换自身无控制台错误或告警。
- [ ] 390px 从 Mobile Sidebar 入口打开时仍会触发既有 Sidebar Dialog title 告警,与本次应用切换组件无关,未扩大范围修改。
## 2026-08-26 跨应用五项能力
- [x] 应用归属判定为完整功能白名单“党风廉政建设”。
- [x] Grid `shortTitle` 只控制 title 展示列,操作列入口保留。
- [x] Detail `shortTitle` 随当前 Detail View 更新,Modal 功能标题始终保留。
- [x] `viewAuth/settings.auth/disableColumns` 只读取 `incorrupt_party_build` 授权;无权限不查询、不显示操作。
- [x] 对齐问题整改 `c047ec4`:稳定 `APPLICATION_NAME` 统一为 `incorrupt_party_build`,不合并其它应用身份,授权类型只接受 group/role/permission。
- [x] 对齐问题整改 `6763c26`:Detail 有有效 `auth/disableColumns` 时整套优先,两项均无配置时整体回退 Grid;不合并、不逐字段回退。
- [x] 无真实可访问 Grid View 时不再为管理员或本应用“管理”角色创建静态兜底 View。
- [x] 同步本机 `$xinyuan-design-system` 跨应用契约并通过 Skill 校验,移除旧的 Grid-only 详情授权规则。
- [x] `/notifications`、消息/待办、全部/未读、搜索、固定 10 条分页、单条/全部已读、动态 action 与跳转。
- [x] Navbar 原生 Badge Bell,0 隐藏、1-99 原值、100+ 为 `99+`,Sidebar 无通知项。
- [x] 文件字段按 `multiple` 支持材料中心与本地上传;材料远程搜索标题/文件名并只保存文件快照。
- [x] 本地文件保存时建档、全部成功后更新业务记录、失败重试跳过已建档项、取消不建档。
- [x] 管理员/管理角色/普通/无权限浏览器 fixture:其它应用“管理”不串权;本应用“管理”和系统管理员可旁路 `auth`,但均不能旁路 Detail `disableColumns`;普通无权限时新增/行操作和 `/record/query` 均为 0。
- [x] Detail 权限来源浏览器 fixture:完整配置通过 `GET /subject/:subject/view/:view` 加载;Detail 有配置时优先、无配置或无实际 Detail 时整体回退 Grid;Detail 只配置其它字段时,当前展示字段不会逐字段回退 Grid。
- [x] 零 Grid 浏览器 fixture:管理员也不建立兜底 View,新增按钮和 `/record/query` 均为 0;当前真实后端前五个 Subject 无 Grid View,因此按合同显示无可访问视图。
- [x] 通知 0/1/99/100、已读、全部已读、动态 action 与内外链浏览器 fixture 验收:Badge 分别为隐藏/1/99/99+;搜索、未读筛选、第 2 页、单条/全部已读均命中原接口;动态 action 发送 `{notice_id}`;站内链接落到当前 base,外链新窗口打开。
- [x] 适用文件字段的材料选择、本地上传、取消、失败与重试浏览器 fixture 验收:当前六类台账的活动文件字段均为 `multiple: true`,无活动单值文件字段;材料中心打开态和远程列表已验证。本地上传夹具中上传 1 次、材料建档 1 次、业务保存 2 次,首次业务失败后弹窗保留,重试不重复建档;取消夹具中上传 1 次、材料/业务创建均为 0。
- [x] 文件 payload 验收:业务附件只包含 `{name,type,size,url}`,不含材料 ID 或内部重试标记;材料记录 `source_app` 为 `incorrupt_party_build`。
......@@ -46,3 +46,12 @@
## 可访问性与验收
所有纯图标操作需要 Tooltip 和可访问名称;Modal 保持焦点约束;Popover、Dropdown、Tooltip 和 Modal 必须继承根主题。验收覆盖 1440x900、1280x720、768x1024、390x844,包含浅色、深色、Sidebar、打开态弹层、选择状态和移动端溢出。
## 跨应用能力
- 本应用显示名“党风廉政建设”属于完整功能白名单,实施 Grid/Detail `shortTitle`、Subject View 权限、消息通知、材料中心与保存时建档五项能力。
- 稳定应用标识为 `APPLICATION_NAME = "incorrupt_party_build"`;授权只读取 `user.applications[]` 中该应用项的角色、组和权限,不合并其它应用或顶层角色。管理员、系统账号及本应用“管理”角色可旁路 `viewAuth/auth`,但不能旁路 `disableColumns`。
- Grid View 缺少 `viewAuth` 时普通用户默认拒绝;无可访问 Grid View 时不得查询记录或展示新增、批量、选择和行操作。仅一个可访问 View 时隐藏二级 Tabs。
- 详情字段权限优先使用当前 Detail 的完整 `auth/disableColumns`;Detail 两项均未有效配置或不存在实际 Detail 时整体回退当前 Grid。Detail 与 Grid 不合并、不逐字段回退,`columns` 与字段授权独立。
- Navbar 通知使用 `Badge.Anchor + Badge + Button + Tooltip`,Bell 固定在主题按钮左侧;Sidebar 不增加通知入口。应用路由为 `/notifications`。
- 文件字段按运行时 `multiple` 使用“材料中心 / 本地上传”。材料选择仅保存 `{name,type,size,url}`;本地上传只形成草稿,保存详情时才以 `source_app: incorrupt_party_build` 创建 `am_material`。
......@@ -73,4 +73,16 @@
## 工具能力说明
当前会话未暴露 HeroUI Pro MCP 的 `list_components` / `get_component_docs` 工具。组件 API 以当前仓库已安装包的真实 exports、类型声明和确认基准实现为依据;不得猜测 API。浏览器门禁必须在可用的应用内浏览器实例出现后执行,期间不得标记完成。
当前会话未暴露 HeroUI Pro MCP 的 `list_components` / `get_component_docs` 工具。组件 API 以当前仓库已安装包的真实 exports、类型声明和确认基准实现为依据;不得猜测 API。2026-08-26 已改用 Playwright CLI 隔离会话完成四视口、深浅主题、权限、通知与文件事务浏览器门禁,结果记录于迁移清单。
## 2026-08-26 五项能力审计
- 范围判定:本应用为“党风廉政建设”,属于六个完整功能应用;实施功能 1-5。
- Subject View:六个 Subject 中前五个无 View;`integrity_party_holiday_reminder` 有一个 Grid View `view_TqTjZv`。迁移前页面未加载 View、未执行 `viewAuth/settings.auth/disableColumns`,也未支持 `shortTitle`。
- 授权结构:当前账号 Andy 为 `admin` 且 `applications=[]`,用于管理员旁路验收;授权边界固定为 `incorrupt_party_build`。
- 文件协议:现有文件字段为 `{name,type,size,url}[]`,上传调用 `/upload`;迁移前不会创建 `am_material`。新流程保留快照协议并新增保存时建档。
- 通知协议:`DataService.js` 已有 `queryUserNotice/readUserNotice/unReadNoticeCount/exacteActionFunction`,迁移前无通知路由与 Bell。
- 路由与 Shell:base 为 `/incorrupt_party_build`;站内通知链接只剥离该 base,外部或其它应用链接新窗口打开。
- 应用切换:当前 `ApplicationSwitcherDialog` 已使用 HeroUI Modal,`sm:grid-cols-2` 严格两列,无需结构变更。
- 2026-08-26 权限复查:按问题整改 `c047ec4` 增加稳定 `APPLICATION_NAME = "incorrupt_party_build"`,授权仅取当前应用项;按 `6763c26` 改为 Detail 完整字段权限优先、无配置时整体回退 Grid,并移除管理员无真实 Grid 时的伪造兜底 View。
- 当前真实后端前五个台账 Subject 没有 Grid View。移除兜底后这些分类对管理员也不发起 Record 查询,需由后台配置真实 Grid View 后才可访问;这是最新跨应用权限合同的预期行为,不再以静态页面配置代替授权 View。
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
......@@ -58,6 +59,7 @@ export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined);
......
......@@ -23,6 +23,8 @@ function getInitialTheme() {
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
title,
......@@ -60,6 +62,8 @@ export default function AppShell({
/>
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={() => history.push(notificationPath)}
theme={theme}
title={title}
onThemeChange={setTheme}
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
export default function DashboardNavbar({ notificationCount = 0, onOpenNotifications, onThemeChange, theme, title }) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const count = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -20,6 +21,15 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Badge.Anchor>
<Button isIconOnly aria-label="通知消息" className="app-navbar-icon-button rc-navbar-icon-button" size="sm" variant="ghost" onPress={onOpenNotifications}>
<Bell size={16} />
</Button>
{count > 0 ? <Badge aria-label={`${count}条未读通知`} color="danger" size="sm">{count > 99 ? '99+' : count}</Badge> : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
<Tooltip delay={0}>
<Button
isIconOnly
......
......@@ -9,6 +9,7 @@ export default function FileUploadField({
description = '将文件拖到此处,或从本地选择。',
disabled = false,
fileName = '',
multiple = false,
onFilesChange,
triggerLabel = '选择文件',
}) {
......@@ -29,9 +30,9 @@ export default function FileUploadField({
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(files.slice(0, 1));
emitFiles(multiple ? files : files.slice(0, 1));
},
[emitFiles],
[emitFiles, multiple],
);
return (
......@@ -44,6 +45,7 @@ export default function FileUploadField({
<DropZone.Input
accept={accept}
disabled={disabled}
multiple={multiple}
onSelect={emitFiles}
/>
<DropZone.Icon>
......
import {
ChevronDown,
Close,
FIELD_SUFFIX_ICON_STYLE,
Search,
} from '@/components/AppIcons';
import {
Autocomplete,
Label,
ListBox,
SearchField,
} from '@heroui/react';
import { ChevronDown, Close, FIELD_SUFFIX_ICON_STYLE, Search } from '@/components/AppIcons';
import { Autocomplete, Label, ListBox, SearchField, Spinner } from '@heroui/react';
function filterOption(textValue, inputValue) {
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
const optionKey = (option) => option?.id ?? option?.value;
export default function XinyuanAutocomplete({
ariaLabel,
className,
emptyText = '暂无可选项',
isDisabled = false,
isRequired = false,
onSelectionChange,
options = [],
placeholder = '请选择',
selectedKey,
variant = 'secondary',
'aria-label': ariaLabelProp, ariaLabel, className, emptyText = '暂无可选项', errorText = '', filter,
fullWidth = true, inputValue, isDisabled = false, isLoading = false, isOpen, isRequired = false, name,
onInputChange, onOpenChange, onSelectionChange, options = [], placeholder = '请选择', placement = 'bottom start',
popoverHeader, renderOption, searchPlaceholder = '输入关键词搜索', selectedKey, selectedKeys,
selectionMode = 'single', shouldFilter = true, valueText, variant = 'secondary',
}) {
const selectedOption = options.find(
(option) => String(option.value) === String(selectedKey ?? ''),
);
const effectiveAriaLabel = ariaLabelProp || ariaLabel || placeholder;
const selectedSet = new Set(selectionMode === 'multiple' ? Array.from(selectedKeys || []).map(String) : selectedKey === null || selectedKey === undefined || selectedKey === '' ? [] : [String(selectedKey)]);
const selectedOptions = options.filter((option) => selectedSet.has(String(optionKey(option))));
const resolvedValueText = valueText ?? (selectedOptions.length ? selectedOptions.map((option) => option.label).join('、') : placeholder);
const selectionProps = selectionMode === 'multiple' ? { selectedKeys } : { selectedKey: selectedKey || null };
const visibleOptions = isLoading || errorText ? [] : options;
return (
<Autocomplete
aria-label={ariaLabel || placeholder}
className={className}
fullWidth
isDisabled={isDisabled}
isRequired={isRequired}
selectedKey={selectedKey || null}
variant={variant}
onClear={() => onSelectionChange?.('')}
onSelectionChange={(key) => onSelectionChange?.(key ? String(key) : '')}
>
<Autocomplete {...selectionProps} aria-label={effectiveAriaLabel} className={className} fullWidth={fullWidth} isDisabled={isDisabled} isOpen={isOpen} isRequired={isRequired} name={name} selectionMode={selectionMode} variant={variant} onOpenChange={onOpenChange} onSelectionChange={(key) => onSelectionChange?.(key ? String(key) : '')}>
<Autocomplete.Trigger>
<Autocomplete.Value>
<span
className="block min-w-0 truncate"
title={selectedOption?.label || placeholder}
>
{selectedOption?.label || placeholder}
</span>
</Autocomplete.Value>
{selectedKey ? (
<Autocomplete.ClearButton aria-label="清空选择">
<Close
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</Autocomplete.ClearButton>
) : null}
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
<Autocomplete.Value><span className="block min-w-0 truncate" title={resolvedValueText}>{resolvedValueText}</span></Autocomplete.Value>
{selectionMode === 'single' && selectedKey ? <Autocomplete.ClearButton aria-label="清空选择" onPress={() => onSelectionChange?.('')}><Close className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4" size={16} style={FIELD_SUFFIX_ICON_STYLE} /></Autocomplete.ClearButton> : null}
<Autocomplete.Indicator><ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} /></Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement="bottom start">
<Autocomplete.Filter filter={filterOption}>
<SearchField
aria-label="搜索选项"
fullWidth
variant="secondary"
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder="输入关键词搜索" />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox
aria-label={ariaLabel || placeholder}
items={options}
renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4 text-sm text-muted">
{emptyText}
</div>
)}
>
{(option) => (
<ListBox.Item
id={String(option.value)}
isDisabled={option.disabled}
textValue={option.label}
>
<Label className="truncate" title={option.label}>
{option.label}
</Label>
<ListBox.ItemIndicator />
</ListBox.Item>
)}
<Autocomplete.Popover placement={placement}>
<Autocomplete.Filter filter={shouldFilter ? filter || defaultFilter : () => true} inputValue={inputValue} onInputChange={onInputChange}>
{popoverHeader}
<SearchField aria-label={searchPlaceholder} fullWidth variant={variant}><SearchField.Group><SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon><SearchField.Input autoFocus placeholder={searchPlaceholder} /><SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton></SearchField.Group></SearchField>
<ListBox aria-label={effectiveAriaLabel} items={visibleOptions} renderEmptyState={() => <div className="grid min-h-20 place-items-center px-3 py-4" data-slot="empty-state">{isLoading ? <Spinner size="sm" /> : errorText || emptyText}</div>}>
{(option) => <ListBox.Item id={String(optionKey(option))} isDisabled={option.isDisabled ?? option.disabled} textValue={option.textValue || option.label}>{renderOption ? renderOption(option) : <Label className="truncate" title={option.label}>{option.label}</Label>}<ListBox.ItemIndicator /></ListBox.Item>}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
......
export const APP_NAME = '党风廉政建设';
export const APP_ROUTE_NAME = 'incorrupt_party_build';
export const APPLICATION_NAME = 'incorrupt_party_build';
export const APP_ROUTE_NAME = APPLICATION_NAME;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const APP_BASE_PATH = `/${APP_ROUTE_NAME}`;
export const LOGIN_PATH = '/login';
......
import AppShell from '@/components/AppShell';
import { BarChart, ClipboardDocumentListIcon } from '@/components/AppIcons';
import { UserContext } from '@/wrapper/Auth';
import { useContext, useMemo, useState } from 'react';
import { loadUnreadNotificationCount, NOTIFICATION_STATE_EVENT } from './notifications/notificationService';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'umi';
const menuGroups = [
......@@ -28,18 +29,34 @@ export default function ProjectManagerLayout() {
const location = useLocation();
const userInfo = useContext(UserContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const title = useMemo(() => {
for (const group of menuGroups) {
const current = group.items.find((item) => item.path === location.pathname);
if (current) return current.name;
}
if (location.pathname === '/notifications') return '消息通知';
return '驾驶舱';
}, [location.pathname]);
useEffect(() => {
let cancelled = false;
const refresh = async () => {
try { const count = await loadUnreadNotificationCount(); if (!cancelled) setUnreadNotificationCount(count); }
catch { if (!cancelled) setUnreadNotificationCount(0); }
};
refresh();
window.addEventListener(NOTIFICATION_STATE_EVENT, refresh);
const timer = window.setInterval(refresh, 60000);
return () => { cancelled = true; window.removeEventListener(NOTIFICATION_STATE_EVENT, refresh); window.clearInterval(timer); };
}, [location.pathname]);
return (
<AppShell
title={title}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
open={sidebarOpen}
onOpenChange={setSidebarOpen}
menuGroups={menuGroups}
......
......@@ -11,6 +11,7 @@
- 所有业务字段均写入 `metadata`
- 附件字段均按多附件数组保存,由系统内上传控件维护
- Excel 导入不处理附件,导出时输出已上传附件的文件名和 URL
- Subject View 的 `shortTitle`、`viewAuth`、`auth` 与 `disableColumns` 控制列表展示和字段编辑权限
## 2. 功能入口
......@@ -49,16 +50,21 @@
9. 完整字段在编辑弹窗中维护
10. DataGrid 固定每页展示 10 条,不提供页容量切换;通过底部 Pagination 翻页
11. 勾选记录后显示 ActionBar,集中提供导出勾选、批量修改和批量删除入口;清空选择后 ActionBar 关闭
12. Grid View `shortTitle=true` 时只隐藏标题列,标题数据和操作列入口保持不变;只有一个可访问 View 时不显示二级 View Tabs
13. 普通用户缺少 View 授权时不查询数据,也不显示新增、批量、选择或行操作;系统管理员及本应用“管理”角色按规则旁路
14. 权限只读取稳定应用标识 `incorrupt_party_build` 对应的 `user.applications[]` 项;其它应用的角色、组和权限不参与判断
15. 没有真实可访问 Grid View 时不建立兜底视图,即使是管理员也不查询该 Subject
### 4.2 手动新建台账
1. 用户点击 `新建记录`
2. 前端根据当前 Tab 字段配置在 HeroUI Modal 中生成表单
3. 用户填写最小必填字段
4. 附件字段通过 DropZone 上传,一个字段可保存多个附件
5. 前端自动生成 `title`
6. 前端调用 `POST /record`
7. 保存成功后提示并刷新当前列表
4. 附件字段按运行时 `multiple` 支持“材料中心 / 本地上传”;材料中心查询 `am_material` 最近 20 条并搜索标题或文件名
5. 材料中心选择只写入 `{name,type,size,url}` 快照;本地文件调用 `/upload` 后仅进入详情草稿
6. 用户保存时先逐文件创建 `am_material`,`source_app` 为 `incorrupt_party_build`;全部成功后再调用 `POST /record`
7. 建档失败保留草稿和已成功建档标记,本次重试不重复建档;取消编辑不创建材料主档
8. 前端自动生成 `title`,保存成功后提示并刷新当前列表
最小必填字段:
......@@ -76,8 +82,18 @@
3. 用户修改字段或上传、移除附件
4. 前端调用 `PUT /record/:name`
5. 保存成功后刷新当前列表
6. 当前 Detail View 存在有效 `auth` 或 `disableColumns` 时整套优先作为字段权限;两项都未配置或不存在实际 Detail 时整体回退当前 Grid,二者不合并或逐字段回退
7. Detail `columns` 仅决定展示字段及顺序;普通用户在选定权限来源中缺少字段 `auth` 时只读,`disableColumns` 对管理员也始终只读
### 4.4 删除台账
### 4.4 消息通知
1. 用户点击 Navbar 主题按钮左侧 Bell 进入 `/notifications`,Sidebar 不提供通知入口
2. 页面支持消息/待办、全部/未读、300ms 搜索和固定 10 条分页
3. 用户可单条已读、全部已读,并执行后端返回的动态 action;action 参数仅为 `{notice_id}`
4. 站内链接仅去除 `/incorrupt_party_build` base 后在当前应用跳转;其它应用和外链新窗口打开
5. 通知内容按纯文本展示,不执行 HTML;未读 Badge 为 0 时隐藏,100 及以上显示 `99+`
### 4.5 删除台账
1. 用户在编辑弹窗中点击删除,或在表格勾选后点击批量删除
2. 页面弹出受控 HeroUI 删除确认弹窗
......@@ -85,7 +101,7 @@
4. 删除后刷新当前列表
5. 删除默认走软删除
### 4.5 Excel 导入
### 4.6 Excel 导入
1. 用户在当前 Tab 点击 `下载模板`
2. 前端导出当前分类的独立模板
......@@ -96,7 +112,7 @@
7. 当前阶段不做重复判断、不自动覆盖,导入全部新增
8. 导入完成后提示成功条数和失败条数
### 4.6 Excel 导出
### 4.7 Excel 导出
1. 用户点击 `Excel 导出`
2. 前端按当前筛选条件分页读取全部记录
......@@ -104,7 +120,7 @@
4. 附件字段导出为文件名和 URL,多附件用换行分隔
5. 用户也可以先勾选记录,再点击 `导出勾选`
### 4.7 批量修改
### 4.8 批量修改
1. 用户勾选一条或多条记录
2. 点击 `批量修改`
......
import { ArrowPathIcon, Delete } from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, Button, Label, Tabs, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { queryAuditMaterials, uploadIntegrityPartyFile } from '../integrityPartyMaterialService';
const toFiles = (value) => Array.isArray(value) ? value.filter(Boolean) : value ? [value] : [];
const fileKey = (file = {}) => file.url || [file.name, file.type, file.size].filter(Boolean).join('|');
function uniqueFiles(files) { return [...new Map(files.map((file) => [fileKey(file), file]).filter(([key]) => key)).values()]; }
export default function IntegrityPartyMaterialField({ editSessionId, fieldPath, multiple = false, onChange, onPendingChange, value, disabled = false }) {
const [tab, setTab] = useState('material-center');
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const [localKeys, setLocalKeys] = useState(() => new Set());
const files = useMemo(() => (multiple ? toFiles(value) : toFiles(value).slice(0, 1)), [multiple, value]);
useEffect(() => { setTab('material-center'); setKeyword(''); setDebouncedKeyword(''); setError(''); setLocalKeys(new Set()); }, [editSessionId]);
useEffect(() => { const timer = window.setTimeout(() => setDebouncedKeyword(keyword), 300); return () => window.clearTimeout(timer); }, [keyword]);
useEffect(() => {
if (tab !== 'material-center') return undefined;
let cancelled = false; setLoading(true); setError('');
queryAuditMaterials(debouncedKeyword).then((items) => { if (!cancelled) setMaterials(items); }).catch((requestError) => { if (!cancelled) { setMaterials([]); setError(requestError?.message || '审计材料加载失败,请稍后重试。'); } }).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [debouncedKeyword, retry, tab]);
function selectMaterial(key) {
const material = materials.find((item) => String(item.id) === String(key || ''));
if (!material?.fileAsset?.url) return;
const snapshot = { name: material.fileAsset.name, type: material.fileAsset.type || 'application/octet-stream', size: Number(material.fileAsset.size || 0), url: material.fileAsset.url };
if (multiple) onChange(uniqueFiles([...files, snapshot]));
else { setLocalKeys(new Set()); onPendingChange(fieldPath, { type: 'replace', materials: [] }); onChange(snapshot); }
}
async function uploadLocal(selected) {
const inputFiles = multiple ? toFiles(selected) : toFiles(selected).slice(0, 1);
if (inputFiles.length === 0) return;
setUploading(true); setError('');
const uploaded = [];
try {
for (const file of inputFiles) uploaded.push(await uploadIntegrityPartyFile(file));
const pending = uploaded.map((asset) => ({ clientId: fileKey(asset), fileAsset: asset, materialRecordId: null }));
setLocalKeys((current) => new Set(multiple ? [...current, ...pending.map((item) => item.clientId)] : pending.map((item) => item.clientId)));
onPendingChange(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending });
onChange(multiple ? uniqueFiles([...files, ...uploaded]) : uploaded[uploaded.length - 1]);
} catch (uploadError) { setError(uploadError?.message || '附件上传失败,请稍后重试。'); }
finally { setUploading(false); }
}
function removeFile(file, index) {
const key = fileKey(file);
setLocalKeys((current) => { const next = new Set(current); next.delete(key); return next; });
onPendingChange(fieldPath, { type: 'remove', clientId: key });
onChange(multiple ? files.filter((_, itemIndex) => itemIndex !== index) : null);
}
const options = materials.map((item) => ({ ...item, label: item.materialTitle, textValue: `${item.materialTitle} ${item.fileAsset?.name || ''}` }));
return <div className="space-y-4">
{files.length ? <div className="space-y-2">{files.map((file, index) => <div className="flex min-w-0 items-center justify-between gap-3" key={`${fileKey(file)}-${index}`}><div className="min-w-0"><div className="truncate text-sm font-medium">{file.name || file.url}</div><div className="mt-1 text-xs text-muted">{localKeys.has(fileKey(file)) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}</div></div><Tooltip><Button isIconOnly aria-label={`删除附件:${file.name || index + 1}`} isDisabled={disabled || uploading} size="sm" variant="danger-soft" onPress={() => removeFile(file, index)}><Delete size={16} /></Button><Tooltip.Content>删除当前附件</Tooltip.Content></Tooltip></div>)}</div> : <span className="text-sm text-muted">未上传</span>}
<Tabs aria-label="附件来源" selectedKey={tab} variant="secondary" onSelectionChange={(key) => setTab(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full"><Tabs.List><Tabs.Tab id="material-center">材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id="local-upload">本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer>
<Tabs.Panel className="pt-3" id="material-center"><XinyuanAutocomplete ariaLabel="选择审计材料" emptyText="没有找到可选择的审计材料" inputValue={keyword} isDisabled={disabled || uploading} isLoading={loading} options={options} placeholder={files.length && multiple ? `已选 ${files.length} 个附件,可继续添加` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" shouldFilter={false} variant="secondary" onInputChange={setKeyword} onSelectionChange={selectMaterial} renderOption={(item) => <span className="min-w-0"><Label className="block truncate">{item.materialTitle}</Label><span className="mt-0.5 block truncate text-xs text-muted">{[item.fileAsset?.name, item.archiveCategory, item.archiveYear].filter(Boolean).join(' · ')}</span></span>} />{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={() => setRetry((value) => value + 1)}><ArrowPathIcon size={14} />重试</Button></Alert> : null}</Tabs.Panel>
<Tabs.Panel className="pt-3" id="local-upload"><FileUploadField description={multiple ? '可选择多个文件;保存详情时同步至审计材料中心。' : '保存详情时同步至审计材料中心。'} disabled={disabled || uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={uploadLocal} />{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}</Tabs.Panel>
</Tabs>
</div>;
}
......@@ -23,12 +23,22 @@ import {
Disclosure,
Input,
SearchField,
Surface,
Tabs,
TextArea,
Toast,
} from '@heroui/react';
import clsx from 'clsx';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { UserContext } from '@/wrapper/Auth';
import {
hasFieldAuthorizationConfiguration,
isFieldDisabledByView,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
import { EmptyState } from '@heroui-pro/react/empty-state';
import IntegrityPartyMaterialField from './IntegrityPartyMaterialField';
import { createIntegrityPartyMaterial } from '../integrityPartyMaterialService';
import {
DEFAULT_INTEGRITY_PAGE_SIZE,
INTEGRITY_LEDGER_CONFIG_MAP,
......@@ -42,10 +52,10 @@ import {
exportSelectedLedgerWorkbook,
importLedgerWorkbook,
loadLedgerPage,
loadLedgerViewResources,
previewLedgerWorkbook,
updateLedgerRecord,
updateLedgerRecords,
uploadLedgerAttachment,
} from '../integrityPartyService';
function LedgerCategoryTab({ tabKey, label }) {
......@@ -78,20 +88,6 @@ function FilterField({ label, children, className }) {
);
}
function formatFileSize(size = 0) {
const numericSize = Number(size || 0);
if (!numericSize) {
return '';
}
if (numericSize < 1024) {
return `${numericSize} B`;
}
if (numericSize < 1024 * 1024) {
return `${(numericSize / 1024).toFixed(1)} KB`;
}
return `${(numericSize / 1024 / 1024).toFixed(1)} MB`;
}
function formatCellValue(field, value) {
if (field?.type === 'boolean') {
if (value === true || value === 'true') {
......@@ -149,89 +145,14 @@ const TABLE_COLUMN_WIDTHS = {
};
const DEFAULT_TABLE_COLUMN_WIDTH = 156;
function AttachmentField({ field, value = [], onChange, disabled }) {
const [uploading, setUploading] = useState(false);
const files = Array.isArray(value) ? value : [];
async function handleUpload(selectedFiles) {
if (selectedFiles.length === 0) {
return;
}
setUploading(true);
try {
const uploadedFiles = [];
for (const file of selectedFiles) {
uploadedFiles.push(await uploadLedgerAttachment(file));
}
onChange([...files, ...uploadedFiles]);
} finally {
setUploading(false);
}
}
return (
<div>
<div className="grid gap-3">
<FileUploadField
ariaLabel={`上传${field.label}`}
disabled={disabled || uploading}
fileName={uploading ? '上传中...' : ''}
onFilesChange={handleUpload}
triggerLabel={`上传${field.label}`}
/>
<Description>已上传 {files.length} 个</Description>
</div>
{files.length > 0 ? (
<div className="mt-3 space-y-2">
{files.map((file, index) => (
<Surface
key={`${file.name || file.url}-${index}`}
className="flex min-w-0 items-center justify-between gap-3 p-3"
variant="secondary"
>
<div className="min-w-0">
{file.url ? (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="block truncate underline"
>
{file.name || file.url}
</a>
) : (
<div className="truncate text-sm">
{file.name || '未命名附件'}
</div>
)}
<Description>
{[file.type, formatFileSize(file.size)].filter(Boolean).join(' · ') || '系统附件'}
</Description>
</div>
<Button
type="button"
isDisabled={disabled}
size="sm"
variant="danger-soft"
onPress={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
>
移除
</Button>
</Surface>
))}
</div>
) : null}
</div>
);
}
function FieldControl({ field, value, onChange, compact = false }) {
function FieldControl({ field, value, onChange, compact = false, disabled = false, editSessionId, onPendingMaterialChange }) {
if (field.type === 'select') {
return (
<XinyuanAutocomplete
ariaLabel={field.label}
isRequired={field.required}
isDisabled={disabled}
options={(field.options || []).map((option) => ({
label: option,
value: option,
......@@ -247,6 +168,7 @@ function FieldControl({ field, value, onChange, compact = false }) {
return (
<XinyuanAutocomplete
ariaLabel={field.label}
isDisabled={disabled}
options={[
{ label: '是', value: 'true' },
{ label: '否', value: 'false' },
......@@ -262,6 +184,7 @@ function FieldControl({ field, value, onChange, compact = false }) {
return (
<TextArea
value={value ?? ''}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
fullWidth
variant="secondary"
......@@ -273,21 +196,26 @@ function FieldControl({ field, value, onChange, compact = false }) {
if (field.type === 'file') {
return (
<AttachmentField
field={field}
value={Array.isArray(value) ? value : []}
<IntegrityPartyMaterialField
disabled={disabled}
editSessionId={editSessionId}
fieldPath={field.name}
multiple={field.multiple}
value={value}
onChange={onChange}
onPendingChange={onPendingMaterialChange}
/>
);
}
if (field.type === 'date') {
return <FormDatePicker value={value ?? ''} onChange={onChange} type="date" />;
return <FormDatePicker disabled={disabled} value={value ?? ''} onChange={onChange} type="date" />;
}
return (
<Input
fullWidth
disabled={disabled}
type={field.type === 'number' ? 'number' : 'text'}
value={value ?? ''}
variant="secondary"
......@@ -297,7 +225,7 @@ function FieldControl({ field, value, onChange, compact = false }) {
);
}
function LedgerForm({ config, form, setForm, fields = null, compact = false }) {
function LedgerForm({ config, form, setForm, fields = null, compact = false, isFieldDisabled = () => false, editSessionId, onPendingMaterialChange }) {
const visibleFields = fields || config.fields;
return (
......@@ -316,6 +244,9 @@ function LedgerForm({ config, form, setForm, fields = null, compact = false }) {
field={field}
value={form[field.name]}
compact={compact}
disabled={isFieldDisabled(field)}
editSessionId={editSessionId}
onPendingMaterialChange={onPendingMaterialChange}
onChange={(nextValue) =>
setForm((current) => ({
...current,
......@@ -349,6 +280,7 @@ function createEmptyFilters(config) {
}
export default function IntegrityPartyPage() {
const userInfo = useContext(UserContext);
const [activeKey, setActiveKey] = useState(INTEGRITY_LEDGER_CONFIGS[0].key);
const activeConfig = INTEGRITY_LEDGER_CONFIG_MAP[activeKey];
const [filters, setFilters] = useState(() => createInitialFilters());
......@@ -374,7 +306,14 @@ export default function IntegrityPartyPage() {
const [batchSaving, setBatchSaving] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState({ open: false, mode: 'single' });
const [filtersExpanded, setFiltersExpanded] = useState(false);
const [viewResourcesLoaded, setViewResourcesLoaded] = useState(false);
const [gridViews, setGridViews] = useState([]);
const [allViews, setAllViews] = useState([]);
const [activeViewName, setActiveViewName] = useState('');
const [activeDetailViewName, setActiveDetailViewName] = useState('');
const reloadRequestIdRef = useRef(0);
const pendingLocalMaterialsRef = useRef({});
const [detailSessionId, setDetailSessionId] = useState(0);
useEffect(() => {
if (!notice) {
......@@ -395,8 +334,51 @@ export default function IntegrityPartyPage() {
() => records.filter((record) => selectedIds.includes(record.id)),
[records, selectedIds],
);
const activeGridView = gridViews.find((view) => view.name === activeViewName) || gridViews[0] || null;
const detailViews = useMemo(() => allViews.filter((view) => view?.type === 'detail'), [allViews]);
const activeDetailView = detailViews.find((view) => view.name === activeDetailViewName) || detailViews[0] || null;
const detailDisplayView = activeDetailView || activeGridView;
const detailAuthorizationView = hasFieldAuthorizationConfiguration(activeDetailView)
? activeDetailView
: activeGridView;
const canAccessLedger = viewResourcesLoaded && Boolean(activeGridView);
const detailFields = useMemo(() => {
const configured = detailDisplayView?.settings?.columns;
if (!Array.isArray(configured) || configured.length === 0) return activeConfig.fields;
const names = configured
.filter((column) => !column?.hide && column?.field !== 'title')
.map((column) => String(column?.field || '').replace(/^metadata\./, ''));
const fields = names.map((name) => activeConfig.fields.find((field) => field.name === name)).filter(Boolean);
return fields.length > 0 ? fields : activeConfig.fields;
}, [activeConfig, detailDisplayView]);
useEffect(() => {
let cancelled = false;
setViewResourcesLoaded(false);
setGridViews([]);
setAllViews([]);
setRecords([]);
setTotal(0);
loadLedgerViewResources(activeKey, userInfo).then((resources) => {
if (cancelled) return;
const nextViews = resources.views || [];
const nextDetailViews = (resources.allViews || []).filter((view) => view?.type === 'detail');
setGridViews(nextViews);
setAllViews(resources.allViews || []);
setActiveViewName(nextViews[0]?.name || '');
setActiveDetailViewName(nextDetailViews[0]?.name || '');
setViewResourcesLoaded(true);
});
return () => { cancelled = true; };
}, [activeKey, userInfo]);
async function reload(nextPage = page, nextFilters = filters[activeKey]) {
if (!canAccessLedger) {
setRecords([]);
setTotal(0);
setSelectedIds([]);
return;
}
const requestId = reloadRequestIdRef.current + 1;
reloadRequestIdRef.current = requestId;
setLoading(true);
......@@ -426,7 +408,7 @@ export default function IntegrityPartyPage() {
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeKey, filters]);
}, [activeKey, activeViewName, canAccessLedger, filters]);
function updateFilter(name, value) {
setFilters((current) => ({
......@@ -440,6 +422,8 @@ export default function IntegrityPartyPage() {
}
function openCreateDialog() {
pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1);
setDetailMode('create');
setActiveRecord(null);
setDetailForm(createEmptyLedgerForm(activeKey));
......@@ -447,6 +431,8 @@ export default function IntegrityPartyPage() {
}
function openEditDialog(record) {
pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1);
setDetailMode('edit');
setActiveRecord(record);
setDetailForm({
......@@ -465,6 +451,13 @@ export default function IntegrityPartyPage() {
setSaving(true);
setError('');
try {
for (const materials of Object.values(pendingLocalMaterialsRef.current)) {
for (const material of materials) {
if (material.materialRecordId) continue;
const createdMaterial = await createIntegrityPartyMaterial({ fileAsset: material.fileAsset, currentUser: userInfo });
material.materialRecordId = createdMaterial.id;
}
}
if (detailMode === 'create') {
const created = await createLedgerRecord(activeKey, detailForm);
setNotice(`记录“${created.title}”已创建。`);
......@@ -473,6 +466,7 @@ export default function IntegrityPartyPage() {
setNotice(`记录“${updated.title}”已保存。`);
}
setDetailOpen(false);
pendingLocalMaterialsRef.current = {};
await reload(page);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
......@@ -481,6 +475,26 @@ export default function IntegrityPartyPage() {
}
}
function handlePendingMaterialChange(fieldPath, action) {
const current = pendingLocalMaterialsRef.current[fieldPath] || [];
if (action?.type === 'replace') {
pendingLocalMaterialsRef.current[fieldPath] = action.materials || [];
} else if (action?.type === 'add') {
const byId = new Map([...current, ...(action.materials || [])].map((item) => [item.clientId, item]));
pendingLocalMaterialsRef.current[fieldPath] = [...byId.values()];
} else if (action?.type === 'remove') {
pendingLocalMaterialsRef.current[fieldPath] = current.filter((item) => item.clientId !== action.clientId);
}
}
function handleDetailOpenChange(nextOpen) {
setDetailOpen(nextOpen);
if (!nextOpen) {
pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1);
}
}
async function confirmDelete() {
setSaving(true);
setError('');
......@@ -665,6 +679,14 @@ export default function IntegrityPartyPage() {
const expandedFilterItems = filterItems.slice(3);
const tableColumns = useMemo(
() => [
...(!shouldHideViewTitleColumn(activeGridView)
? [{
key: 'title',
title: '标题',
minWidth: 220,
render: (record) => <DataTableText text={record.title} showTooltip />,
}]
: []),
...activeConfig.tableColumns.map((fieldName) => ({
key: fieldName,
title: fieldMap[fieldName]?.label || fieldName,
......@@ -676,7 +698,7 @@ export default function IntegrityPartyPage() {
/>
),
})),
{
...(canAccessLedger ? [{
key: 'actions',
title: '操作',
isActions: true,
......@@ -697,9 +719,9 @@ export default function IntegrityPartyPage() {
/>
</TableActionGroup>
),
},
}] : []),
],
[activeConfig, fieldMap],
[activeConfig, activeGridView, canAccessLedger, fieldMap],
);
return (
......@@ -741,6 +763,26 @@ export default function IntegrityPartyPage() {
</Alert>
) : null}
{gridViews.length > 1 ? (
<Tabs selectedKey={activeViewName} onSelectionChange={(key) => { setActiveViewName(String(key)); setPage(0); setSelectedIds([]); }}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label={`${activeConfig.label}视图`} className="w-max min-w-max">
{gridViews.map((view) => <LedgerCategoryTab key={view.name} tabKey={view.name} label={view.title || view.name} />)}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
{!viewResourcesLoaded ? (
<div className="flex min-h-48 items-center justify-center"><span className="text-sm text-muted">正在加载视图权限...</span></div>
) : !canAccessLedger ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>暂无可访问视图</EmptyState.Title>
<EmptyState.Description>当前账号未获得该台账的 Grid View 访问权限。</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
) : <>
<XinyuanFilterSurface>
<Disclosure
isExpanded={filtersExpanded}
......@@ -869,15 +911,16 @@ export default function IntegrityPartyPage() {
onPageChange={(nextPage) => reload(Math.max(nextPage, 0))}
/>
</section>
</>}
<XinyuanModal
isOpen={detailOpen}
onOpenChange={setDetailOpen}
onOpenChange={handleDetailOpenChange}
size="4xl"
title={detailMode === 'create' ? `新建${activeConfig.tabLabel}记录` : '编辑台账记录'}
footer={
<>
<Button type="button" size="sm" variant="secondary" onPress={() => setDetailOpen(false)}>
<Button type="button" size="sm" variant="secondary" onPress={() => handleDetailOpenChange(false)}>
取消
</Button>
<Button type="button" isPending={saving} size="sm" variant="primary" onPress={saveDetail}>
......@@ -886,7 +929,26 @@ export default function IntegrityPartyPage() {
</>
}
>
<LedgerForm config={activeConfig} form={detailForm} setForm={setDetailForm} />
{detailViews.length > 1 ? (
<Tabs className="mb-5" selectedKey={activeDetailViewName} onSelectionChange={(key) => setActiveDetailViewName(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0"><Tabs.List aria-label="详情视图" className="w-max min-w-max">{detailViews.map((view) => <LedgerCategoryTab key={view.name} tabKey={view.name} label={view.title || view.name} />)}</Tabs.List></Tabs.ListContainer>
</Tabs>
) : null}
{detailMode === 'edit' && activeRecord && !shouldHideViewTitleColumn(detailDisplayView) ? (
<Surface className="mb-5 p-4" variant="secondary">
<Description>记录标题</Description>
<div className="mt-1 text-sm font-medium">{activeRecord.title}</div>
</Surface>
) : null}
<LedgerForm
config={activeConfig}
fields={detailFields}
form={detailForm}
setForm={setDetailForm}
editSessionId={detailSessionId}
onPendingMaterialChange={handlePendingMaterialChange}
isFieldDisabled={(field) => isFieldDisabledByView(`metadata.${field.name}`, detailAuthorizationView, userInfo)}
/>
</XinyuanModal>
<XinyuanModal
......@@ -978,6 +1040,7 @@ export default function IntegrityPartyPage() {
setForm={setBatchForm}
fields={activeConfig.batchFields.map((fieldName) => fieldMap[fieldName]).filter(Boolean)}
compact
isFieldDisabled={(field) => isFieldDisabledByView(`metadata.${field.name}`, activeGridView, userInfo)}
/>
</XinyuanModal>
......
import dayjs from 'dayjs';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
import { APPLICATION_NAME } from '@/config/ApplicationConfig';
const MATERIAL_SUBJECT = 'am_material';
function recordsFrom(response) {
return [response, response?.data, response?.data?.list, response?.data?.records, response?.list, response?.records, response?.items, response?.result]
.find((item) => Array.isArray(item)) || [];
}
const escapeRegExp = (value) => String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const inferTitle = (name) => String(name || '').replace(/\.[^.]+$/, '').trim() || '未命名材料';
function normalizeFileAsset(result, file) {
const candidates = [result?.file, result?.data?.file, result?.data, result?.result?.file, result?.result, result];
for (const item of candidates) {
if (!item || typeof item !== 'object') continue;
const url = item.url || item.path || item.location || item?.data?.url || '';
if (!url) continue;
return { name: item.name || item.filename || file?.name || '未命名文件', type: item.type || item.mimetype || file?.type || 'application/octet-stream', size: Number(item.size || file?.size || 0), url };
}
throw new Error('文件上传成功,但未获取到可用的文件地址。');
}
export async function queryAuditMaterials(keyword = '') {
const text = escapeRegExp(String(keyword || '').trim());
const filter = text ? { $or: [
{ 'metadata.material_title': { $regex: text, $options: 'i' } },
{ 'metadata.file_asset.name': { $regex: text, $options: 'i' } },
] } : {};
const response = await queryRecords({ subject: MATERIAL_SUBJECT, page: 0, page_size: 20, filter, sort: '-updated_at -created_at', mode: 'full' });
return recordsFrom(response).map((record) => {
const metadata = record?.metadata || {};
return {
id: record._id || record.id || record.name || '',
materialTitle: metadata.material_title || record.title || metadata.file_asset?.name || '未命名材料',
fileAsset: metadata.file_asset || {},
archiveCategory: metadata.archive_category || '',
archiveYear: metadata.archive_year || '',
};
}).filter((item) => item.id && item.fileAsset?.url);
}
export async function uploadIntegrityPartyFile(file) {
if (!file) throw new Error('请选择要上传的本地文件。');
return normalizeFileAsset(await uploadFile(file), file);
}
export async function createIntegrityPartyMaterial({ fileAsset, currentUser }) {
if (!fileAsset?.url) throw new Error('缺少有效的文件地址,无法写入审计材料中心。');
const uploader = currentUser?._id || currentUser?.id || '';
if (!uploader) throw new Error('未获取到当前登录用户,无法写入审计材料中心。');
const materialTitle = inferTitle(fileAsset.name);
const response = await createRecord({
subject: MATERIAL_SUBJECT,
title: materialTitle,
content_type: 'text',
metadata: {
material_title: materialTitle,
file_asset: { name: fileAsset.name, type: fileAsset.type || 'application/octet-stream', size: Number(fileAsset.size || 0), url: fileAsset.url },
source_app: APPLICATION_NAME,
uploader,
uploaded_at: dayjs().toISOString(),
},
});
const record = response?.data || response?.result || response;
return { id: record?._id || record?.id || record?.name || true, record };
}
import {
countRecords,
createRecord,
getSubjectView,
getSubjectViewDetail,
loadSubject,
queryRecords,
removeRecord,
updateRecord,
uploadFile,
} from '@/services/DataService';
import { isViewAccessible } from '@/utils/subjectView';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
export const DEFAULT_INTEGRITY_PAGE_SIZE = 10;
const viewResourceCache = new Map();
export async function loadLedgerViewResources(configKey, user) {
const config = getConfig(configKey);
const cacheKey = config.subject;
if (!viewResourceCache.has(cacheKey)) {
const promise = Promise.all([loadSubject(config.subject), getSubjectView(config.subject)])
.then(async ([subject, summaries]) => {
const views = await Promise.all((Array.isArray(summaries) ? summaries : []).map(async (view) => {
if (!view?.name) return view;
try {
const detail = await getSubjectViewDetail(config.subject, view.name);
return { ...view, ...detail, settings: detail?.settings || view?.settings || {} };
} catch {
return view;
}
}));
return { subject, views };
})
.catch(() => ({ subject: null, views: [] }));
viewResourceCache.set(cacheKey, promise);
}
const resources = await viewResourceCache.get(cacheKey);
const configuredGridViews = resources.views.filter((view) => view?.type === 'grid');
const accessibleGridViews = configuredGridViews.filter((view) => isViewAccessible(view, user));
return { ...resources, allViews: resources.views, views: accessibleGridViews };
}
export const TRAINING_CATEGORY_OPTIONS = [
'廉政党课讲授',
'廉洁合规培训',
......@@ -1206,33 +1237,6 @@ export async function exportLedgerTemplateWorkbook(configKey) {
};
}
function findUploadUrl(response) {
const candidates = [
response?.url,
response?.data?.url,
response?.result?.url,
response?.path,
response?.data?.path,
response?.result?.path,
response?.file?.url,
response?.data?.file?.url,
];
return candidates.find(Boolean) || '';
}
export async function uploadLedgerAttachment(file) {
const response = await uploadFile(file);
const url = findUploadUrl(response);
return {
name: file.name,
type: file.type,
size: file.size,
url,
};
}
function buildGroup(list, keyGetter, labelGetter) {
return Object.values(
list.reduce((accumulator, item) => {
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Button, Card, Chip, SearchField, Spinner, Tabs, toast } from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi';
import {
executeNotificationAction,
extractNotificationTarget,
getNotificationContent,
getNotificationSender,
isNotificationRead,
loadNotificationPage,
loadUnreadNotificationCount,
markAllNotificationsRead,
markNotificationRead,
NOTIFICATION_PAGE_SIZE,
resolveNotificationTarget,
} from './notificationService';
const CATEGORY_OPTIONS = [{ key: 'message', label: '消息' }, { key: 'todo', label: '待办' }];
const READ_OPTIONS = [{ key: 'all', label: '全部' }, { key: 'unread', label: '未读' }];
function formatTime(value) {
if (!value) return '时间未知';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(date);
}
function NotificationItem({ notification, category, currentUserId, isBusy, onAction, onMarkRead, onOpenTarget }) {
const isRead = isNotificationRead(notification, currentUserId);
const sender = getNotificationSender(notification);
const target = resolveNotificationTarget(extractNotificationTarget(notification));
const actions = Array.isArray(notification?.actions) ? notification.actions : [];
return (
<Card>
<Card.Content className="flex flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 items-start gap-3">
<Avatar className="shrink-0" size="sm"><Avatar.Fallback>{sender.trim().slice(0, 1) || '系'}</Avatar.Fallback></Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="break-words text-sm font-medium sm:text-base">{notification?.title || (category === 'todo' ? '待办通知' : '通知消息')}</h2>
<p className="mt-1 text-xs text-muted sm:text-sm">{sender} · {formatTime(notification?.sent_at || notification?.created_at)}</p>
</div>
<Chip color={isRead ? undefined : 'danger'} size="sm" variant="soft">{isRead ? (category === 'todo' ? '已完成' : '已读') : category === 'todo' ? '待处理' : '未读'}</Chip>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6">{getNotificationContent(notification) || '暂无通知内容'}</p>
</div>
</div>
<div className="flex min-h-8 flex-wrap justify-end gap-2">
{!isRead ? <Button isDisabled={isBusy} size="sm" variant="ghost" onPress={() => onMarkRead(notification)}>标为已读</Button> : null}
{category === 'todo' ? actions.map((action, index) => (
<Button isDisabled={isBusy || isRead} key={`${action?.label || 'action'}-${index}`} size="sm" variant="secondary" onPress={() => onAction(notification, action)}>{action?.label || '处理'}</Button>
)) : null}
{target ? <Button isDisabled={isBusy} size="sm" variant="primary" onPress={() => onOpenTarget(notification, target)}><OpenExternal size={14} />进入流程</Button> : null}
</div>
</Card.Content>
</Card>
);
}
export default function NotificationListPage() {
const userInfo = useContext(UserContext);
const [category, setCategory] = useState('message');
const [readStatus, setReadStatus] = useState('all');
const [searchText, setSearchText] = useState('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [unread, setUnread] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const timer = window.setTimeout(() => { setKeyword(searchText.trim()); setPage(0); }, 300);
return () => window.clearTimeout(timer);
}, [searchText]);
useEffect(() => {
let cancelled = false;
setLoading(true); setError('');
loadNotificationPage({ page, category, readStatus, keyword })
.then((result) => { if (!cancelled) { setItems(result.list); setTotal(result.total); } })
.catch((requestError) => { if (!cancelled) { setItems([]); setTotal(0); setError(requestError?.message || '通知列表加载失败,请稍后重试'); } })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [category, keyword, page, readStatus, reloadKey]);
useEffect(() => {
let cancelled = false;
loadUnreadNotificationCount().then((count) => { if (!cancelled) setUnread(count); }).catch(() => { if (!cancelled) setUnread(0); });
return () => { cancelled = true; };
}, [reloadKey]);
const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const run = useCallback(async (key, operation, message) => {
setBusyKey(key);
try { await operation(); toast.success(message, { timeout: 3000 }); refresh(); return true; }
catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); return false; }
finally { setBusyKey(''); }
}, [refresh]);
const openTarget = useCallback(async (notification, target) => {
setBusyKey(notification.name);
try {
if (!isNotificationRead(notification, userInfo?._id)) await markNotificationRead(notification.name);
if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer');
else history.push(target.href);
refresh();
} catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 }); }
finally { setBusyKey(''); }
}, [refresh, userInfo?._id]);
return (
<div className="rc-page-stack">
<div className="rc-page-toolbar rc-page-toolbar-responsive">
<Tabs selectedKey={category} onSelectionChange={(key) => { setCategory(String(key)); setPage(0); }}>
<Tabs.ListContainer><Tabs.List aria-label="通知分类">{CATEGORY_OPTIONS.map((option) => <Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
</Tabs>
<Button isDisabled={loading || unread === 0} isPending={busyKey === 'all'} variant="secondary" onPress={() => run('all', markAllNotificationsRead, '全部通知已标记为已读')}>全部已读</Button>
</div>
<div className="rc-page-toolbar">
<SearchField aria-label="搜索通知内容" className="w-full sm:w-80 lg:w-96" value={searchText} variant="primary" onChange={setSearchText}>
<SearchField.Group><SearchField.SearchIcon><MagnifyingGlassIcon size={16} /></SearchField.SearchIcon><SearchField.Input placeholder="搜索通知内容" /><SearchField.ClearButton aria-label="清空通知搜索"><Close size={12} /></SearchField.ClearButton></SearchField.Group>
</SearchField>
<Tabs selectedKey={readStatus} onSelectionChange={(key) => { setReadStatus(String(key)); setPage(0); }}>
<Tabs.ListContainer><Tabs.List aria-label="通知状态">{READ_OPTIONS.map((option) => <Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
</Tabs>
</div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={refresh}>重试</Button></Alert> : null}
{loading && items.length === 0 ? <div className="flex min-h-64 items-center justify-center"><Spinner size="sm" /></div> : null}
{!loading && !error && items.length === 0 ? <EmptyState className="min-h-64" size="md"><EmptyState.Media><Bell size={22} /></EmptyState.Media><EmptyState.Header><EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title><EmptyState.Description>{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}</EmptyState.Description></EmptyState.Header></EmptyState> : null}
{items.length > 0 ? <div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>{items.map((item) => <NotificationItem category={category} currentUserId={userInfo?._id} isBusy={busyKey === item.name} key={item.name || item._id} notification={item} onAction={(notification, action) => run(notification.name, () => executeNotificationAction(notification, action), '待办已处理')} onMarkRead={(notification) => run(notification.name, () => markNotificationRead(notification.name), '已标记为已读')} onOpenTarget={openTarget} />)}</div> : null}
{!error && total > 0 ? <DataTablePagination isDisabled={loading} itemLabel="条通知" page={page} pageSize={NOTIFICATION_PAGE_SIZE} total={total} onPageChange={setPage} /> : null}
</div>
);
}
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
import { APP_BASE_PATH } from '@/config/ApplicationConfig';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'incorrupt-party-build:notification-state';
const toArray = (value) => (Array.isArray(value) ? value : []);
const toId = (value) => String(value && typeof value === 'object' ? value._id || value.id || value.name || '' : value || '');
const escapeRegExp = (value) => String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
export async function loadNotificationPage({ page = 0, pageSize = NOTIFICATION_PAGE_SIZE, category = 'message', readStatus = 'all', keyword = '' } = {}) {
const filter = { category };
const normalizedKeyword = String(keyword || '').trim();
if (normalizedKeyword) filter.content = { $regex: escapeRegExp(normalizedKeyword), $options: 'i' };
const response = await queryUserNotice(page, pageSize, filter, readStatus === 'unread' ? 'unread' : null);
return {
list: toArray(response?.list),
page: Number(response?.current_page ?? page) || 0,
pageSize: Number(response?.page_size ?? pageSize) || pageSize,
total: Number(response?.total_notices ?? response?.total ?? 0) || 0,
};
}
export async function loadUnreadNotificationCount() {
const response = await unReadNoticeCount();
return Math.max(0, Number(response?.total_notices ?? response?.count ?? response?.total ?? response ?? 0) || 0);
}
function notifyChanged() {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
export async function markNotificationRead(name) {
if (!name) throw new Error('通知缺少已读标识');
await readUserNotice(name);
notifyChanged();
}
export async function markAllNotificationsRead() {
await readUserNotice();
notifyChanged();
}
export async function executeNotificationAction(notification, action) {
const actionName = typeof action?.action === 'string' ? action.action : action?.action?.name || action?.name;
const methodName = action?.method_name || action?.methodName;
if (!actionName || !methodName) throw new Error('该待办操作缺少执行配置');
const result = await exacteActionFunction(actionName, methodName, { notice_id: notification?._id });
notifyChanged();
return result;
}
export function isNotificationRead(notification, userId) {
const id = toId(userId);
return Boolean(id) && toArray(notification?.data?.logs).some((log) => toId(log?.user_id) === id);
}
export function getNotificationSender(notification) {
return notification?.from?.display_name || notification?.from?.username || notification?.created_by?.display_name || notification?.created_by?.username || '系统';
}
export function getNotificationContent(notification) {
return String(notification?.content || notification?.content_compile || '').trim();
}
function trimTarget(value) {
return String(value || '').replace(/[),.;!?,。;!?)]+$/u, '');
}
export function extractNotificationTarget(notification) {
const configured = notification?.settings?.url || notification?.settings?.link || notification?.data?.url || notification?.data?.link;
if (configured) return trimTarget(configured);
return trimTarget(getNotificationContent(notification).match(/https?:\/\/[^\s<>"']+|\/incorrupt_party_build\/[^\s<>"']+/)?.[0]);
}
export function resolveNotificationTarget(target) {
const normalized = trimTarget(target);
if (!normalized) return null;
if (/^https?:\/\//i.test(normalized)) {
try {
const url = new URL(normalized);
if (url.pathname === APP_BASE_PATH || url.pathname.startsWith(`${APP_BASE_PATH}/`)) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch { return null; }
}
if (normalized === APP_BASE_PATH) return { href: '/', external: false };
if (normalized.startsWith(`${APP_BASE_PATH}/`)) return { href: normalized.slice(APP_BASE_PATH.length), external: false };
return normalized.startsWith('/') ? { href: normalized, external: true } : null;
}
import { AUTHORIZATION_APPLICATION_NAME } from '@/config/ApplicationConfig';
export function toArray(value) {
if (value === null || value === undefined || value === '') return [];
return Array.isArray(value) ? value : [value];
}
function normalize(value) {
return String(value ?? '').trim();
}
function identityValues(values) {
return toArray(values)
.flatMap((value) =>
value && typeof value === 'object'
? [value._id, value.name, value.display_name, value.username]
: value,
)
.map(normalize)
.filter(Boolean);
}
function applicationAuthorization(
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
const application = toArray(user?.applications).find(
(item) => normalize(item?.name) === normalize(applicationName),
);
return {
groups: identityValues(application?.groups),
roles: identityValues(application?.roles),
permissions: identityValues(application?.permissions),
};
}
function intersects(left, right) {
const expected = new Set(identityValues(right));
return identityValues(left).some((item) => expected.has(item));
}
export function isSystemAdministrator(user) {
return user?.type === 'admin' || user?.type === 'system';
}
export function hasApplicationManagementAccess(
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (isSystemAdministrator(user)) return true;
return intersects(applicationAuthorization(user, applicationName).roles, ['管理']);
}
export function hasAuthAccess(
authRule,
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (!user) return false;
if (hasApplicationManagementAccess(user, applicationName)) return true;
const authorization = applicationAuthorization(user, applicationName);
if (typeof authRule === 'string') return intersects(authorization.roles, [authRule]);
if (authRule?.type === 'group') return intersects(authorization.groups, authRule.groups);
if (authRule?.type === 'role') return intersects(authorization.roles, authRule.roles);
if (authRule?.type === 'permission') return intersects(authorization.permissions, authRule.permissions);
return false;
}
function configuredRule(rule) {
return ['group', 'role', 'permission'].includes(rule?.type)
? rule
: '管理';
}
export function isViewAccessible(
view,
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
return hasAuthAccess(
configuredRule(view?.settings?.viewAuth),
user,
applicationName,
);
}
export function hasFieldAuthorizationConfiguration(view) {
const settings = view?.settings;
return (
toArray(settings?.auth).some((rule) => normalize(rule?.field)) ||
toArray(settings?.disableColumns).some((column) => normalize(column?.field))
);
}
export function isFieldDisabledByView(
fieldPath,
view,
user,
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (toArray(view?.settings?.disableColumns).some((item) => item?.field === fieldPath)) {
return true;
}
if (hasApplicationManagementAccess(user, applicationName)) return false;
const rule = toArray(view?.settings?.auth).find((item) => item?.field === fieldPath);
return !hasAuthAccess(configuredRule(rule), user, applicationName);
}
export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
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