Commit fa6182cc authored by David Yang's avatar David Yang

feat: align view permissions and add notifications

parent ba8d2186
...@@ -11,6 +11,7 @@ const ROUTE_PATHS = { ...@@ -11,6 +11,7 @@ const ROUTE_PATHS = {
businessDashboard: '/integrity_risk_table/business_dashboard', businessDashboard: '/integrity_risk_table/business_dashboard',
problemRectification: '/problem_rectification', problemRectification: '/problem_rectification',
issueRectificationArchive: '/issue_rectification_archive', issueRectificationArchive: '/issue_rectification_archive',
notifications: '/notifications',
problemRectificationForm: '/problem_rectification_form', problemRectificationForm: '/problem_rectification_form',
problemRectificationFlowTable: '/problem_rectification_flow_table', problemRectificationFlowTable: '/problem_rectification_flow_table',
} as const; } as const;
...@@ -56,6 +57,10 @@ export default defineConfig({ ...@@ -56,6 +57,10 @@ export default defineConfig({
path: ROUTE_PATHS.issueRectificationArchive, path: ROUTE_PATHS.issueRectificationArchive,
component: './problem_rectification/archive/index', component: './problem_rectification/archive/index',
}, },
{
path: ROUTE_PATHS.notifications,
component: './problem_rectification/notifications/NotificationListPage',
},
{ {
path: ROUTE_PATHS.problemRectificationForm, path: ROUTE_PATHS.problemRectificationForm,
component: './problem_rectification/form/index', component: './problem_rectification/form/index',
......
...@@ -106,6 +106,9 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系 ...@@ -106,6 +106,9 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
## 6. DataGrid 与分页 ## 6. DataGrid 与分页
- 所有业务列表使用 HeroUI Pro DataGrid,原生承载选择、排序、列宽、固定列和空状态。 - 所有业务列表使用 HeroUI Pro DataGrid,原生承载选择、排序、列宽、固定列和空状态。
- Subject View 的 `settings.shortTitle = true` 统一解释为仅隐藏 `title` 展示列;共享列解析器必须保留标题数据在查询、排序、权限和详情上下文中的作用。标题列承载的业务信息与入口必须通过 `RecordWorkbench.contextColumns` 迁移到独立、可见的上下文列,并继续在固定操作列保留关键入口;禁止因隐藏标题而同时移除明细、详情、状态查看或行操作能力。上下文列不属于 `settings.columns` 业务字段,不参与数据查询、排序或字段权限。
- `type = "detail"` 的视图使用同一 `shortTitle` 语义:为 `true` 时隐藏详情弹框 Header 中的记录标题和字段区中的 `title` 字段;功能标题必须始终保留作为 Modal 的可访问名称。存在多个详情视图时,以当前激活视图为准并随页签切换即时更新。
- Subject Grid 权限必须与历史多维表格一致:`viewAuth` 只决定视图可见性,字段 `auth` 只决定编辑能力;无 `viewAuth` 的视图和无字段 `auth` 的字段对普通用户默认拒绝。系统管理员和当前业务所属历史应用的“管理”角色可旁路两类授权,但 `disableColumns` 对所有账号始终只读。授权数据只从当前历史应用对应的 `user.applications[]` 项读取,不得跨应用合并角色、用户组或权限。由 Grid 进入详情弹框时,Grid 视图是唯一字段编辑权限来源,Detail 视图不得再次叠加字段权限。
- 选择列使用 `showSelectionCheckboxes`,不得手写选择列或在 Cell 内嵌旧 Checkbox。 - 选择列使用 `showSelectionCheckboxes`,不得手写选择列或在 Cell 内嵌旧 Checkbox。
- 固定列、最小列宽和横向滚动是数据可读性行为,不通过 CSS 改写 DataGrid 表头、行高、边框、背景或 Hover。 - 固定列、最小列宽和横向滚动是数据可读性行为,不通过 CSS 改写 DataGrid 表头、行高、边框、背景或 Hover。
- DataGrid 无论列数多少都占满列表可用宽度。非宽表使用 HeroUI Pro 原生列尺寸:选择列和操作列保持固定,第一业务主列使用 `1fr` 吸收剩余空间,表格内容保持 `min-width: 100%`;只有真实最小列宽超过视口时才使用像素列宽和内部横向滚动,不允许出现表面全宽但正文只停在左侧的空白断层。 - DataGrid 无论列数多少都占满列表可用宽度。非宽表使用 HeroUI Pro 原生列尺寸:选择列和操作列保持固定,第一业务主列使用 `1fr` 吸收剩余空间,表格内容保持 `min-width: 100%`;只有真实最小列宽超过视口时才使用像素列宽和内部横向滚动,不允许出现表面全宽但正文只停在左侧的空白断层。
......
import BankOutlined from '@ant-design/icons/BankOutlined'; import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined'; import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined'; import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined'; import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined'; import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined'; import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
...@@ -62,6 +63,7 @@ export const Bank = withSize(BankOutlined); ...@@ -62,6 +63,7 @@ export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined); export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined); export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined); export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined); export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined); export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined); export const ChevronDown = withSize(DownOutlined);
......
...@@ -3,6 +3,7 @@ import xyLogoWhite from '@/assets/xy-logo-white.png'; ...@@ -3,6 +3,7 @@ import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application'; import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react'; import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { Heading } from 'react-aria-components';
import { AppGrid, LogOut } from './AppIcons'; import { AppGrid, LogOut } from './AppIcons';
function getUserDisplay(userInfo) { function getUserDisplay(userInfo) {
...@@ -132,6 +133,11 @@ function SidebarContents({ ...@@ -132,6 +133,11 @@ function SidebarContents({
<Icon size={16} /> <Icon size={16} />
</Sidebar.MenuIcon> </Sidebar.MenuIcon>
<Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel> <Sidebar.MenuLabel>{item.name}</Sidebar.MenuLabel>
{Number(item.count) > 0 ? (
<Sidebar.MenuChip>
{Number(item.count) > 99 ? '99+' : Number(item.count)}
</Sidebar.MenuChip>
) : null}
</Sidebar.MenuItem> </Sidebar.MenuItem>
); );
})} })}
...@@ -154,9 +160,9 @@ export default function DashboardSidebar(props) { ...@@ -154,9 +160,9 @@ export default function DashboardSidebar(props) {
<SidebarContents {...props} /> <SidebarContents {...props} />
</Sidebar> </Sidebar>
<Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar"> <Sidebar.Mobile className="app-sidebar-glass rc-mobile-sidebar">
<h2 slot="title" className="sr-only"> <Heading slot="title" className="sr-only">
{APPLICATION_DISPLAY_NAME}导航 {APPLICATION_DISPLAY_NAME}导航
</h2> </Heading>
<SidebarContents {...props} /> <SidebarContents {...props} />
</Sidebar.Mobile> </Sidebar.Mobile>
</> </>
......
...@@ -459,6 +459,7 @@ export default function RecordWorkbench({ ...@@ -459,6 +459,7 @@ export default function RecordWorkbench({
onQuery, onQuery,
title, title,
columns = EMPTY_ARRAY, columns = EMPTY_ARRAY,
contextColumns = EMPTY_ARRAY,
columnGroups = EMPTY_ARRAY, columnGroups = EMPTY_ARRAY,
records = EMPTY_ARRAY, records = EMPTY_ARRAY,
selectedIds = EMPTY_ARRAY, selectedIds = EMPTY_ARRAY,
...@@ -780,6 +781,27 @@ export default function RecordWorkbench({ ...@@ -780,6 +781,27 @@ export default function RecordWorkbench({
}, },
] ]
: []), : []),
...contextColumns.map((column, columnIndex) => ({
key: column.key,
title: column.title,
width: column.width || 280,
minWidth: column.minWidth || column.width || 240,
pin: column.pin,
isPrimary: column.isPrimary ?? columnIndex === 0,
render: (row) => {
if (row.isExpandedRecord || row.isExpandedPanel) {
return null;
}
return column.render?.({
record: row.record,
expandable: row.expandable,
expanded: row.expanded,
toggleExpanded: () => toggleExpandedRow(row.key),
openDetail,
});
},
})),
...stickyColumns.orderedColumns.map((column, columnIndex) => { ...stickyColumns.orderedColumns.map((column, columnIndex) => {
const stickyMeta = stickyColumns.stickyMeta[column.field]; const stickyMeta = stickyColumns.stickyMeta[column.field];
const columnGroupId = columnGroupMeta.fieldToGroup[column.field]; const columnGroupId = columnGroupMeta.fieldToGroup[column.field];
...@@ -808,7 +830,7 @@ export default function RecordWorkbench({ ...@@ -808,7 +830,7 @@ export default function RecordWorkbench({
: stickyMeta?.side === 'right' : stickyMeta?.side === 'right'
? 'end' ? 'end'
: undefined, : undefined,
isPrimary: columnIndex === 0, isPrimary: contextColumns.length === 0 && columnIndex === 0,
render: (row) => { render: (row) => {
if (row.isExpandedPanel) { if (row.isExpandedPanel) {
return columnIndex === 0 ? row.expandedContent : null; return columnIndex === 0 ? row.expandedContent : null;
......
export const APPLICATION_DISPLAY_NAME = '问题整改'; export const APPLICATION_DISPLAY_NAME = '问题整改';
export const APPLICATION_BASE_PATH = '/problem_rectification_new/'; export const APPLICATION_BASE_PATH = '/problem_rectification_new/';
export const AUTHORIZATION_APPLICATION_NAME = 'integrity_risk_manager';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme'; export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
...@@ -7,6 +8,7 @@ export const ROUTE_TITLES = Object.freeze({ ...@@ -7,6 +8,7 @@ export const ROUTE_TITLES = Object.freeze({
'/integrity_risk_table/business_dashboard': '整改分析', '/integrity_risk_table/business_dashboard': '整改分析',
'/problem_rectification': '问题整改', '/problem_rectification': '问题整改',
'/issue_rectification_archive': '往期材料归档', '/issue_rectification_archive': '往期材料归档',
'/notifications': '通知消息',
'/problem_rectification_form': '整改处理', '/problem_rectification_form': '整改处理',
'/problem_rectification_flow_table': '整改流程', '/problem_rectification_flow_table': '整改流程',
}); });
...@@ -87,9 +87,9 @@ export default function SubjectViewContent({ ...@@ -87,9 +87,9 @@ export default function SubjectViewContent({
const fieldType = field.fieldDef?.type || 'text'; const fieldType = field.fieldDef?.type || 'text';
const fieldLabel = getFieldLabel(field); const fieldLabel = getFieldLabel(field);
const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType); const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const fieldDisabled = const fieldDisabled = isFieldDisabled
isFieldDisabled?.(field.field, view) || ? isFieldDisabled(field.field, view)
isFieldDisabledByView(field.field, view); : isFieldDisabledByView(field.field, view);
return ( return (
<div <div
......
...@@ -20,9 +20,8 @@ import { ...@@ -20,9 +20,8 @@ import {
buildViewColumns, buildViewColumns,
checkViewCondition, checkViewCondition,
getFieldValue, getFieldValue,
isFieldDisabledByView,
isViewAccessible,
setFieldValue, setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
const EMPTY_ARRAY = []; const EMPTY_ARRAY = [];
...@@ -121,7 +120,6 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -121,7 +120,6 @@ const RecordDetailDrawer = (props, ref) => {
const filtered = (views || []) const filtered = (views || [])
.filter((view) => view.type === 'detail') .filter((view) => view.type === 'detail')
.filter((view) => (filterView.length === 0 ? true : filterView.includes(view.name))) .filter((view) => (filterView.length === 0 ? true : filterView.includes(view.name)))
.filter((view) => isViewAccessible(view))
.filter((view) => checkViewCondition(view.settings?.visabled, remoteRecord)); .filter((view) => checkViewCondition(view.settings?.visabled, remoteRecord));
if (filtered.length > 0) { if (filtered.length > 0) {
...@@ -169,6 +167,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -169,6 +167,7 @@ const RecordDetailDrawer = (props, ref) => {
); );
const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0]; const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0];
const hideRecordTitle = shouldHideViewTitleColumn(activeView);
const isRecordLocked = remoteRecord?.metadata?.is_locked === true; const isRecordLocked = remoteRecord?.metadata?.is_locked === true;
const effectiveReadonly = readonly || isRecordLocked; const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo( const disabledFieldSet = useMemo(
...@@ -180,8 +179,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -180,8 +179,7 @@ const RecordDetailDrawer = (props, ref) => {
); );
const isDrawerFieldDisabled = (fieldPath, currentView) => const isDrawerFieldDisabled = (fieldPath, currentView) =>
disabledFieldSet.has(fieldPath) || disabledFieldSet.has(fieldPath) ||
isFieldDisabled(fieldPath, currentView) || isFieldDisabled(fieldPath, authorizationView || currentView);
isFieldDisabledByView(fieldPath, authorizationView);
const hasEditableFields = const hasEditableFields =
Boolean(editableRecord) && Boolean(editableRecord) &&
...@@ -396,9 +394,11 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -396,9 +394,11 @@ const RecordDetailDrawer = (props, ref) => {
<Modal.Header className="pr-16"> <Modal.Header className="pr-16">
<Modal.Heading>{title || '问题整改详情'}</Modal.Heading> <Modal.Heading>{title || '问题整改详情'}</Modal.Heading>
{!hideRecordTitle ? (
<div className="truncate text-sm text-muted" title={recordTitle}> <div className="truncate text-sm text-muted" title={recordTitle}>
{loading ? '正在读取记录信息' : recordTitle} {loading ? '正在读取记录信息' : recordTitle}
</div> </div>
) : null}
</Modal.Header> </Modal.Header>
{detailViews.length > 1 ? ( {detailViews.length > 1 ? (
......
...@@ -6,10 +6,15 @@ import AppShell from '@/components/AppShell'; ...@@ -6,10 +6,15 @@ import AppShell from '@/components/AppShell';
import { import {
Archive, Archive,
BarChart, BarChart,
Bell,
CheckCircleIcon, CheckCircleIcon,
Project, Project,
} from '@/components/AppIcons'; } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/pages/problem_rectification/ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from '@/pages/problem_rectification/ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/pages/problem_rectification/notifications/notificationService';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
...@@ -40,6 +45,12 @@ const MENU_GROUPS = [ ...@@ -40,6 +45,12 @@ const MENU_GROUPS = [
path: '/issue_rectification_archive', path: '/issue_rectification_archive',
icon: Archive, icon: Archive,
}, },
{
name: '通知消息',
path: '/notifications',
icon: Bell,
showsNotificationCount: true,
},
], ],
}, },
]; ];
...@@ -50,6 +61,21 @@ export default function ProblemRectificationLayout() { ...@@ -50,6 +61,21 @@ export default function ProblemRectificationLayout() {
const siteInfo = useContext(SiteContext); const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false); const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const menuGroups = useMemo(
() =>
MENU_GROUPS.map((group) => ({
...group,
items: group.items.map((item) => ({
...item,
count: item.showsNotificationCount
? unreadNotificationCount
: undefined,
})),
})),
[unreadNotificationCount],
);
const currentTitle = useMemo( const currentTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME, () => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
...@@ -60,10 +86,33 @@ export default function ProblemRectificationLayout() { ...@@ -60,10 +86,33 @@ export default function ProblemRectificationLayout() {
document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`; document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [currentTitle]); }, [currentTitle]);
useEffect(() => {
let cancelled = false;
const refreshUnreadCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) setUnreadNotificationCount(count);
} catch (error) {
if (!cancelled) setUnreadNotificationCount(0);
}
};
refreshUnreadCount();
window.addEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
const timer = window.setInterval(refreshUnreadCount, 60000);
return () => {
cancelled = true;
window.removeEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
window.clearInterval(timer);
};
}, [location.pathname]);
return ( return (
<> <>
<AppShell <AppShell
menuGroups={MENU_GROUPS} menuGroups={menuGroups}
onLogout={userInfo?.logout} onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)} onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
open={sidebarOpen} open={sidebarOpen}
......
import { countRecords, getSubjectView, loadRecord, loadSubject, queryRecords } from '@/services/DataService'; import { countRecords, getSubjectView, loadRecord, loadSubject, queryRecords } from '@/services/DataService';
import { import {
buildViewColumns, buildViewColumns,
buildViewSearchColumns,
checkViewCondition, checkViewCondition,
formatFieldValue, formatFieldValue,
getFieldValue, getFieldValue,
...@@ -29,17 +30,20 @@ export function normalizeArchiveBatchRecord(record) { ...@@ -29,17 +30,20 @@ export function normalizeArchiveBatchRecord(record) {
} }
export async function loadArchiveWorkbenchResources() { export async function loadArchiveWorkbenchResources() {
const [archiveRecordsResult, archiveSubject, archiveViews] = await Promise.all([ const [archiveSubject, archiveViews] = await Promise.all([
queryRecords({ loadSubject(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT),
getSubjectView(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT),
]);
const accessibleViews = buildArchiveGridViews(archiveViews || []);
const archiveRecordsResult = accessibleViews.length > 0
? await queryRecords({
subject: ISSUE_RECTIFICATION_ARCHIVE_SUBJECT, subject: ISSUE_RECTIFICATION_ARCHIVE_SUBJECT,
page: 0, page: 0,
page_size: 1000, page_size: 1000,
sort: '-updated_at -created_at', sort: '-updated_at -created_at',
mode: 'full', mode: 'full',
}), })
loadSubject(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT), : { records: [] };
getSubjectView(ISSUE_RECTIFICATION_ARCHIVE_SUBJECT),
]);
const records = (archiveRecordsResult?.records || []).map(normalizeArchiveBatchRecord); const records = (archiveRecordsResult?.records || []).map(normalizeArchiveBatchRecord);
return { return {
...@@ -50,31 +54,37 @@ export async function loadArchiveWorkbenchResources() { ...@@ -50,31 +54,37 @@ export async function loadArchiveWorkbenchResources() {
} }
export function buildArchiveGridViews(views = []) { export function buildArchiveGridViews(views = []) {
const gridViews = views const configuredGridViews = views.filter((view) => view.type === 'grid');
.filter((view) => view.type === 'grid') const gridViews = configuredGridViews.filter((view) => isViewAccessible(view));
.filter((view) => isViewAccessible(view));
if (gridViews.length > 0) { if (gridViews.length > 0 || configuredGridViews.length > 0) {
return gridViews; return gridViews;
} }
return [ const fallbackView = {
{
title: '全部批次', title: '全部批次',
type: 'grid', type: 'grid',
name: 'all', name: 'all',
settings: {}, settings: {},
}, };
]; return isViewAccessible(fallbackView) ? [fallbackView] : [];
} }
export function buildArchiveColumns(view, subject) { export function buildArchiveColumns(view, subject) {
return buildViewColumns(view, subject).filter((column) => column.field !== 'title' || !column.hide); return buildViewColumns(view, subject);
} }
export function buildArchiveConfiguredView(records, filters, gridView, subject) { export function buildArchiveConfiguredView(records, filters, gridView, subject) {
if (!gridView) {
return {
filteredRecords: [],
columns: [],
};
}
const keyword = normalizeText(filters?.keyword).toLowerCase(); const keyword = normalizeText(filters?.keyword).toLowerCase();
const columns = buildArchiveColumns(gridView, subject); const columns = buildArchiveColumns(gridView, subject);
const searchColumns = buildViewSearchColumns(gridView, subject);
const filteredRecords = records.filter((record) => { const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record; const rawRecord = record.raw || record;
...@@ -98,7 +108,7 @@ export function buildArchiveConfiguredView(records, filters, gridView, subject) ...@@ -98,7 +108,7 @@ export function buildArchiveConfiguredView(records, filters, gridView, subject)
return true; return true;
} }
const searchText = columns const searchText = searchColumns
.map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef)) .map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef))
.join(' ') .join(' ')
.toLowerCase(); .toLowerCase();
......
...@@ -51,6 +51,7 @@ import { ...@@ -51,6 +51,7 @@ import {
getFieldValue, getFieldValue,
getSelectOptions, getSelectOptions,
normalizeText, normalizeText,
shouldHideViewTitleColumn,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import { import {
DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE, DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
...@@ -895,6 +896,14 @@ export default function ProblemRectificationListPage() { ...@@ -895,6 +896,14 @@ export default function ProblemRectificationListPage() {
const reloadList = useCallback( const reloadList = useCallback(
async (overrides = {}) => { async (overrides = {}) => {
if (!subject || !activeGridView) { if (!subject || !activeGridView) {
setRecords([]);
setGridViewCounts({});
setPagination({
page: 0,
pageSize,
totalRecords: 0,
totalPages: 1,
});
return; return;
} }
...@@ -1628,7 +1637,7 @@ export default function ProblemRectificationListPage() { ...@@ -1628,7 +1637,7 @@ export default function ProblemRectificationListPage() {
await reloadList(); await reloadList();
setSuccessMessage( setSuccessMessage(
isDepartmentFlow isDepartmentFlow
? `推送成功,已生成 ${departmentAssignments.length} 条部门整改明细,可从记录标题右侧展开查看。` ? `推送成功,已生成 ${departmentAssignments.length} 条部门整改明细,可从问题动态或标题列展开查看。`
: `推送成功,已将 ${selectedRecords.length} 条问题的 ${Array.from(batchDepartmentItemsByRecordId.values()).flat().length} 条部门整改明细发送给填写人。`, : `推送成功,已将 ${selectedRecords.length} 条问题的 ${Array.from(batchDepartmentItemsByRecordId.values()).flat().length} 条部门整改明细发送给填写人。`,
); );
} catch (requestError) { } catch (requestError) {
...@@ -1732,6 +1741,63 @@ export default function ProblemRectificationListPage() { ...@@ -1732,6 +1741,63 @@ export default function ProblemRectificationListPage() {
}} }}
title="问题清单" title="问题清单"
columns={configuredView.columns || []} columns={configuredView.columns || []}
contextColumns={
shouldHideViewTitleColumn(activeGridView)
? [
{
key: 'problem-context',
title: '问题动态',
width: 320,
minWidth: 320,
pin: 'start',
render: ({ record, expandable, expanded, toggleExpanded }) => {
const delayRequestDisplay = resolveDelayRequestDisplay(record);
return (
<div className="flex flex-wrap items-center gap-2">
{expandable ? (
<HeroButton
className="text-accent"
aria-expanded={expanded}
size="sm"
variant="ghost"
onPress={toggleExpanded}
>
<ChevronRightIcon
data-slot="icon"
className={clsx('transition-transform', expanded && 'rotate-90')}
/>
{expanded
? '收起问题明细'
: `${record.departmentItems.length} 条问题明细`}
</HeroButton>
) : (
<span className="text-sm text-muted">暂无问题明细</span>
)}
{delayRequestDisplay ? (
<HeroButton
size="sm"
variant={delayRequestDisplay.tone === 'rose' ? 'danger-soft' : 'secondary'}
title={delayRequestDisplay.title}
aria-label={`查看${record.title || record.projectName || '当前问题'}的延期申请详情`}
onPress={() => setDelayDetailRecord(record)}
>
<CalendarDaysIcon className="size-3.5 shrink-0" aria-hidden="true" />
{delayRequestDisplay.text}
</HeroButton>
) : (
<span className="text-sm text-muted">未发起延期</span>
)}
{record.isLocked ? <StatusPill text="已锁定" tone="amber" /> : null}
</div>
);
},
},
]
: []
}
columnGroups={activeGridView?.settings?.groups || []} columnGroups={activeGridView?.settings?.groups || []}
records={filteredRecords} records={filteredRecords}
selectedIds={selectedIds} selectedIds={selectedIds}
...@@ -1744,7 +1810,7 @@ export default function ProblemRectificationListPage() { ...@@ -1744,7 +1810,7 @@ export default function ProblemRectificationListPage() {
buildDepartmentDetailRecord(record, item, workflowUserLabels), buildDepartmentDetailRecord(record, item, workflowUserLabels),
) )
)} )}
toolbarActions={[ toolbarActions={activeGridView ? [
{ {
key: 'export', key: 'export',
label: '导出', label: '导出',
...@@ -1766,8 +1832,8 @@ export default function ProblemRectificationListPage() { ...@@ -1766,8 +1832,8 @@ export default function ProblemRectificationListPage() {
tone: 'primary', tone: 'primary',
onClick: () => setShowCreateModal(true), onClick: () => setShowCreateModal(true),
}, },
]} ] : []}
bulkActions={[ bulkActions={activeGridView ? [
{ {
key: 'push', key: 'push',
label: '推送整改', label: '推送整改',
...@@ -1794,8 +1860,8 @@ export default function ProblemRectificationListPage() { ...@@ -1794,8 +1860,8 @@ export default function ProblemRectificationListPage() {
disabled: () => selectedIds.length === 0 || hasLockedSelection || actionLoading, disabled: () => selectedIds.length === 0 || hasLockedSelection || actionLoading,
onClick: () => handleDelete(selectedRecords), onClick: () => handleDelete(selectedRecords),
}, },
]} ] : []}
rowActions={[ rowActions={activeGridView ? [
{ {
key: 'view-detail', key: 'view-detail',
label: '查看详情', label: '查看详情',
...@@ -1843,8 +1909,8 @@ export default function ProblemRectificationListPage() { ...@@ -1843,8 +1909,8 @@ export default function ProblemRectificationListPage() {
disabled: ({ record }) => actionLoading || record.isLocked, disabled: ({ record }) => actionLoading || record.isLocked,
onClick: ({ record }) => handleDelete([record]), onClick: ({ record }) => handleDelete([record]),
}, },
]} ] : []}
detailPanel={{ detailPanel={activeGridView ? {
getRecordName: (record) => record.name || record.id, getRecordName: (record) => record.name || record.id,
onDirtyClose: reloadList, onDirtyClose: reloadList,
render: ({ open, recordName, onClose, onDataChange }) => ( render: ({ open, recordName, onClose, onDataChange }) => (
...@@ -1875,15 +1941,17 @@ export default function ProblemRectificationListPage() { ...@@ -1875,15 +1941,17 @@ export default function ProblemRectificationListPage() {
useAuditMaterialCenter useAuditMaterialCenter
/> />
), ),
}} } : null}
states={{ states={{
loading: loading || recordsLoading, loading: loading || recordsLoading,
error, error,
success: '', success: '',
loadingTitle: '正在加载问题整改列表', loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。', loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: '当前视图暂无数据', emptyTitle: activeGridView ? '当前视图暂无数据' : '暂无可访问的视图',
emptyDescription: '可以切换其他视图,或调整检查报告出具月份、严重程度、整改评级、主责部门和重点关注筛选。', emptyDescription: activeGridView
? '可以切换其他视图,或调整检查报告出具月份、严重程度、整改评级、主责部门和重点关注筛选。'
: '当前账号未获得问题整改 Grid 视图的查看权限。',
}} }}
pagination={{ pagination={{
page, page,
......
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_STATUS_OPTIONS = [
{ key: 'all', label: '全部' },
{ key: 'unread', label: '未读' },
];
function formatNotificationTime(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 content = getNotificationContent(notification) || '暂无通知内容';
const target = resolveNotificationTarget(extractNotificationTarget(notification));
const actions = Array.isArray(notification?.actions) ? notification.actions : [];
const avatarText = sender.trim().slice(0, 1).toUpperCase() || '系';
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>{avatarText}</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 text-foreground sm:text-base">
{notification?.title || (category === 'todo' ? '待办通知' : '通知消息')}
</h2>
<p className="mt-1 text-xs text-muted sm:text-sm">
{sender} · {formatNotificationTime(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 text-foreground">
{content}
</p>
</div>
</div>
<div className="flex min-h-8 flex-wrap items-center 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 [notifications, setNotifications] = useState([]);
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = 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,
pageSize: NOTIFICATION_PAGE_SIZE,
category,
readStatus,
keyword,
})
.then((result) => {
if (cancelled) return;
setNotifications(result.list);
setTotal(result.total);
})
.catch((requestError) => {
if (cancelled) return;
setNotifications([]);
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) setUnreadTotal(count);
})
.catch(() => {
if (!cancelled) setUnreadTotal(0);
});
return () => {
cancelled = true;
};
}, [reloadKey]);
const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const runItemOperation = useCallback(
async (key, operation, successMessage) => {
setBusyKey(key);
try {
await operation();
toast.success(successMessage, { timeout: 3000 });
refresh();
return true;
} catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000,
});
return false;
} finally {
setBusyKey('');
}
},
[refresh],
);
const handleMarkRead = useCallback(
(notification) =>
runItemOperation(
notification.name,
() => markNotificationRead(notification.name),
'已标记为已读',
),
[runItemOperation],
);
const handleMarkAllRead = useCallback(async () => {
await runItemOperation(
'all',
markAllNotificationsRead,
'全部通知已标记为已读',
);
}, [runItemOperation]);
const handleAction = useCallback(
(notification, action) =>
runItemOperation(
notification.name,
() => executeNotificationAction(notification, action),
'待办已处理',
),
[runItemOperation],
);
const handleOpenTarget = 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
className="w-fit max-w-full min-w-0"
selectedKey={category}
onSelectionChange={(key) => {
setCategory(String(key));
setPage(0);
}}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="通知分类" className="w-max min-w-max">
{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 || unreadTotal === 0}
isLoading={busyKey === 'all'}
variant="secondary"
onPress={handleMarkAllRead}
>
全部已读
</Button>
</div>
<div className="rc-page-toolbar">
<div className="w-full min-w-0 sm:w-80 lg:w-96">
<SearchField
aria-label="搜索通知内容"
fullWidth
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>
</div>
<Tabs
selectedKey={readStatus}
onSelectionChange={(key) => {
setReadStatus(String(key));
setPage(0);
}}
>
<Tabs.ListContainer>
<Tabs.List aria-label="通知状态">
{READ_STATUS_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 && notifications.length === 0 ? (
<div className="flex min-h-64 items-center justify-center" aria-label="通知加载中">
<Spinner size="sm" />
</div>
) : null}
{!loading && !error && notifications.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}
{notifications.length > 0 ? (
<div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>
{notifications.map((notification) => (
<NotificationItem
category={category}
currentUserId={userInfo?._id}
isBusy={busyKey === notification.name}
key={notification.name || notification._id}
notification={notification}
onAction={handleAction}
onMarkRead={handleMarkRead}
onOpenTarget={handleOpenTarget}
/>
))}
</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';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'problem-rectification:notification-state';
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function toId(value) {
if (value && typeof value === 'object') {
return String(value._id || value.id || value.name || '');
}
return String(value || '');
}
function escapeRegExp(value) {
return 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,
totalPages: Number(response?.total_pages ?? 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,
);
}
export async function markNotificationRead(name) {
if (!name) {
throw new Error('通知缺少已读标识');
}
await readUserNotice(name);
notifyNotificationStateChanged();
}
export async function markAllNotificationsRead() {
await readUserNotice();
notifyNotificationStateChanged();
}
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,
});
notifyNotificationStateChanged();
return result;
}
export function isNotificationRead(notification, userId) {
const normalizedUserId = toId(userId);
if (!normalizedUserId) return false;
return toArray(notification?.data?.logs).some(
(log) => toId(log?.user_id) === normalizedUserId,
);
}
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(target) {
return String(target || '').replace(/[),.;!?,。;!?)]+$/u, '');
}
export function extractNotificationTarget(notification) {
const configuredTarget =
notification?.settings?.url ||
notification?.settings?.link ||
notification?.data?.url ||
notification?.data?.link;
if (configuredTarget) return trimTarget(configuredTarget);
const content = getNotificationContent(notification);
const match = content.match(
/https?:\/\/[^\s<>"']+|\/problem_rectification_new\/[^\s<>"']+|\/problem_rectification(?:_flow_table|_form)(?:\?[^\s<>"']*)?/,
);
return trimTarget(match?.[0]);
}
export function resolveNotificationTarget(target) {
const normalizedTarget = trimTarget(target);
if (!normalizedTarget) return null;
if (/^https?:\/\//i.test(normalizedTarget)) {
try {
const url = new URL(normalizedTarget);
if (url.pathname.startsWith('/problem_rectification_new/')) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch (error) {
return null;
}
}
const appPrefix = '/problem_rectification_new';
if (!normalizedTarget.startsWith(`${appPrefix}/`)) {
return normalizedTarget.startsWith('/')
? { href: normalizedTarget, external: true }
: null;
}
return {
href: normalizedTarget.slice(appPrefix.length),
external: false,
};
}
export function notifyNotificationStateChanged() {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
}
...@@ -61,13 +61,14 @@ ...@@ -61,13 +61,14 @@
- `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab - `type = "grid"` 的视图用于主列表顶部视图切换,替代原先写死的 tab
- `settings.columns` 决定列表列顺序、宽度、固定列位置和排序优先级,列表页不得在视图列后追加自定义业务字段列 - `settings.columns` 决定列表列顺序、宽度、固定列位置和排序优先级,列表页不得在视图列后追加自定义业务字段列
- `settings.groups` 决定列表列分组,例如把多个问题分类字段归入同一分组;前端需展示分组表头,同一分组下的列即使在 `settings.columns` 中不连续,也应在表格内聚拢展示,并支持每个分组独立折叠 / 展开,折叠后隐藏该分组子列 - `settings.groups` 决定列表列分组,例如把多个问题分类字段归入同一分组;前端需展示分组表头,同一分组下的列即使在 `settings.columns` 中不连续,也应在表格内聚拢展示,并支持每个分组独立折叠 / 展开,折叠后隐藏该分组子列
- `settings.shortTitle = true` 仅隐藏列表中的 `title` 展示列,不删除记录标题数据,也不改变标题字段参与的关键词查询、视图排序、详情标题或权限判断;标题列原本承载的部门明细数量 / 展开、延期申请阶段 / 详情和锁定状态迁移到固定的“问题动态”上下文列,详情、延期申请、锁定、复制和删除继续由固定行操作列提供
- `settings.quickSelect` 决定列表是否展示勾选列,用于批量推送、删除等操作 - `settings.quickSelect` 决定列表是否展示勾选列,用于批量推送、删除等操作
- `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围 - `settings.restrictions` 与 `settings.filters.conditions` 共同决定该视图下可见的数据范围
- `settings.viewAuth` 决定当前用户是否能看到该视图 - Grid 视图的 `settings.viewAuth` 决定当前用户是否能看到该视图;普通用户未配置 `viewAuth` 时默认不可见,仅系统管理员和问题整改历史应用 `integrity_risk_manager` 的“管理”角色可见。全部 Grid 视图均无权限时不得生成绕过授权的兜底视图、查询记录或展示新增/批量/行操作入口
- `type = "detail"` 的视图用于详情抽屉字段编排,按 `columns` 顺序渲染字段 - `type = "detail"` 的视图用于详情抽屉字段编排,按 `columns` 顺序渲染字段;当前详情视图 `settings.shortTitle = true` 时,同时隐藏弹框 Header 中的记录标题和字段区中的 `title` 字段,未配置或为 `false` 时正常显示,切换详情视图页签后立即按新视图更新
- 表格 Tab 的 `settings.auth` 按字段路径控制详情抽屉内对应字段是否允许编辑,规则结构与多维表格一致:`field` 使用 `title` 或 `metadata.<fieldName>`,`type` 支持 `group`、`role`、`permission`,授权值分别从 `groups`、`roles`、`permissions` 读取 - 表格 Tab 的 `settings.auth` 按字段路径控制详情弹框内对应字段是否允许编辑,规则结构与多维表格一致:`field` 使用 `title` 或 `metadata.<fieldName>`,`type` 支持 `group`、`role`、`permission`,授权值分别从 `groups`、`roles`、`permissions` 读取;普通用户遇到未配置 `auth` 的字段时默认只读
- 表格 Tab 的 `settings.disableColumns` 与 `settings.auth`、当前详情视图自身的同名配置叠加生效;任一层禁止编辑时字段保留展示但进入只读状态,并显示“当前视图无编辑权限”,不隐藏列或字段 - 表格 Tab 的 `settings.disableColumns` 始终优先,命中后字段保留展示但进入只读状态,即使系统管理员或“管理”角色也不得编辑;未命中时,系统管理员、系统账号和问题整改历史应用 `integrity_risk_manager` 的“管理”角色旁路字段 `auth`
- 管理员、系统账号拥有字段编辑权限旁路;普通用户按当前登录用户的用户组、应用用户组、角色或权限集合匹配,未命中配置时不得编辑。切换表格 Tab 后,详情抽屉必须立即改用新 Tab 的权限配置 - 普通用户只使用 `applications[]` 中 `name = "integrity_risk_manager"` 的用户组、角色和权限进行匹配,不合并顶层用户组或其他应用授权。多维表格配置转换只接受 `group`、`role`、`permission`,缺失或其他类型均按“管理”角色处理。进入详情弹框后,当前 Grid Tab 是唯一的字段编辑权限来源,Detail 视图只负责字段编排、条件显示与 `shortTitle`,两套字段权限不得叠加。切换 Grid Tab 后,详情弹框必须立即改用新 Tab 的权限配置
因此前端新增字段展示、部门视图调整、列顺序变化时,应优先改 Subject View 配置,而不是直接修改页面常量。 因此前端新增字段展示、部门视图调整、列顺序变化时,应优先改 Subject View 配置,而不是直接修改页面常量。
...@@ -79,12 +80,12 @@ ...@@ -79,12 +80,12 @@
- 主列表提供“检查报告出具月份”筛选,用户选择 `YYYY-MM` 后,仅查询 `metadata.inspection_report_date` 位于该月份的记录;该筛选不使用其它检查或整改日期兜底 - 主列表提供“检查报告出具月份”筛选,用户选择 `YYYY-MM` 后,仅查询 `metadata.inspection_report_date` 位于该月份的记录;该筛选不使用其它检查或整改日期兜底
- 主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。 - 主列表固定每页 10 条,不显示页容量选择器;切换视图、修改筛选或清空筛选时回到第 1 页。表头勾选只选择当前页记录,跨页批量处理需用户逐页确认选择范围。
- 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数 - 主列表视图页签数量通过 `/record/count` 按各视图限制和当前筛选条件计算,表格标题区展示服务端筛选后的总数与当前页记录数
- 主列表标题列直接展示可点击的延期申请状态:存在历史问题级延期或部门级延期明细时,按当前阶段显示“延期已发起 / 延期待审批 / 延期待终审 / 延期处理中 / 延期已生效 / 延期已退回”日历徽标,不能等用户再次点击推送后才提示已发起;状态同时使用图标和文字,不只依赖颜色 - 主列表显示标题列时,标题单元格直接展示可点击的延期申请状态:存在历史问题级延期或部门级延期明细时,按当前阶段显示“延期已发起 / 延期待审批 / 延期待终审 / 延期处理中 / 延期已生效 / 延期已退回”日历徽标,不能等用户再次点击推送后才提示已发起;状态同时使用图标和文字,不只依赖颜色。视图启用 `shortTitle` 时,“问题动态”列按相同规则直接展示延期状态和问题明细数量;没有明细或延期时明确显示“暂无问题明细”“未发起延期”,操作列仍保留“查看延期申请”入口
- 点击延期状态后打开“延期申请详情”弹窗,按责任部门展示原预计完成时间、申请完成时间、延期事由、填写人、一级审批人、最终审批人以及三级流程状态;申请完成时间或延期事由尚未提交时明确显示“待填写”,历史问题级延期按“整条问题”展示;行操作区同时提供“查看延期申请”日历按钮,保证窄屏下标题列被固定列压缩时仍可进入详情 - 点击延期状态后打开“延期申请详情”弹窗,按责任部门展示原预计完成时间、申请完成时间、延期事由、填写人、一级审批人、最终审批人以及三级流程状态;申请完成时间或延期事由尚未提交时明确显示“待填写”,历史问题级延期按“整条问题”展示;行操作区同时提供“查看延期申请”日历按钮,保证窄屏下标题列被固定列压缩时仍可进入详情
- 用户单选已有延期申请的记录时,顶部“推送延期申请”按钮改为显示当前延期状态并禁用,避免形成看似可以重复推送的入口 - 用户单选已有延期申请的记录时,顶部“推送延期申请”按钮改为显示当前延期状态并禁用,避免形成看似可以重复推送的入口
- 主列表滚动到表格区域后,表格工作台吸顶展示;标题栏、操作区、列分组和分页固定在工作台内,数据表格区域独立滚动,表头列名在数据区域内继续吸顶,折叠 / 展开列分组时保留当前表格滚动位置 - 主列表滚动到表格区域后,表格工作台吸顶展示;标题栏、操作区、列分组和分页固定在工作台内,数据表格区域独立滚动,表头列名在数据区域内继续吸顶,折叠 / 展开列分组时保留当前表格滚动位置
- 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架 - 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架
- 详情侧栏顶部不展示记录标题、状态和摘要卡片,只保留详情视图切换与全屏 / 关闭操作,字段主体作为主要阅读区域 - 详情弹框顶部始终保留功能标题和全屏 / 关闭操作;记录标题是否展示由当前 `detail` 视图的 `settings.shortTitle` 决定,不展示额外状态或摘要卡片,字段主体作为主要阅读区域
- 详情侧栏内 `reference` 类型字段只读态统一展示为关联明细表:按目标 Subject 的 `title` 与全部 metadata 字段展开,字段值按各自类型格式化;编辑态仍使用引用选择器,保存时只写入关联记录 `_id` 或 `_id[]` - 详情侧栏内 `reference` 类型字段只读态统一展示为关联明细表:按目标 Subject 的 `title` 与全部 metadata 字段展开,字段值按各自类型格式化;编辑态仍使用引用选择器,保存时只写入关联记录 `_id` 或 `_id[]`
- 列表行操作列使用图标按钮横向排列,查看、复制、删除等常用操作通过 tooltip / aria-label 标明含义,避免文字按钮纵向堆叠 - 列表行操作列使用图标按钮横向排列,查看、复制、删除等常用操作通过 tooltip / aria-label 标明含义,避免文字按钮纵向堆叠
- 新增同类列表页时,优先复用工作台与详情抽屉组合,避免再次复制问题整改页 JSX - 新增同类列表页时,优先复用工作台与详情抽屉组合,避免再次复制问题整改页 JSX
...@@ -261,7 +262,7 @@ ...@@ -261,7 +262,7 @@
- 列表页采用分页加载,默认每页 20 条,可切换为 10、50、100;分页、筛选、视图切换均由服务端查询结果驱动 - 列表页采用分页加载,默认每页 20 条,可切换为 10、50、100;分页、筛选、视图切换均由服务端查询结果驱动
- 字段编辑统一进入右侧详情抽屉 - 字段编辑统一进入右侧详情抽屉
- 长文本、附件、状态类字段在详情抽屉中直接维护 - 长文本、附件、状态类字段在详情抽屉中直接维护
- 详情抽屉内字段顺序与显示权限跟随 `detail view` 配置 - 详情弹框内字段顺序与条件显示跟随 `detail view` 配置;从 Grid 进入时,字段编辑权限只跟随当前 Grid 视图配置
--- ---
...@@ -956,3 +957,37 @@ const result = await queryUsers(0, 20, { ...@@ -956,3 +957,37 @@ const result = await queryUsers(0, 20, {
对应对象定义已同步更新至: 对应对象定义已同步更新至:
- [subjectsDefinition.md](/Users/yangkang/Desktop/xy/problem_rectification/src/services/subjectsDefinition.md) - [subjectsDefinition.md](/Users/yangkang/Desktop/xy/problem_rectification/src/services/subjectsDefinition.md)
---
## 17. 通知消息流程
### 17.1 功能入口
“通知消息”作为问题整改应用侧边栏一级入口,路由为 `/notifications`。侧边栏通过 `GET /notice/customer/count` 展示当前用户未读总数,超过 99 条显示 `99+`;未读数为 0 时不展示角标。页面不在顶部导航重复提供通知弹窗。
### 17.2 查询与筛选
1. 进入页面后默认查询 `message` 分类的全部通知,每页 10 条。
2. 用户可在“消息 / 待办”之间切换,并使用“全部 / 未读”状态筛选。
3. 搜索框按通知正文查询,输入停止 300 毫秒后重新从第 1 页请求;正则特殊字符在请求前转义。
4. 未读筛选必须向服务端传递 `status: "unread"`,避免只过滤当前页导致总数和分页错误。
5. 加载中显示进度状态;无数据时区分消息、待办、未读和搜索无结果;请求失败时保留明确错误信息和重试入口。
### 17.3 已读与待办状态
- 通知的 `data.logs` 中存在当前用户 `_id` 时视为已读;`message` 显示“已读 / 未读”,`todo` 显示“已完成 / 待处理”。
- “标为已读”调用 `PUT /notice/customer/read` 并传通知 `name`。
- “全部已读”调用同一接口但不传 `name`,处理当前用户全部通知;未读总数为 0 时按钮禁用。
- 待办按钮完全由通知 `actions` 配置生成,执行时只提交 `{notice_id: 通知 _id}`。操作期间禁用当前通知的重复操作,成功后刷新列表和侧边栏角标,失败时保留原状态并提示错误。
### 17.4 进入关联流程
通知正文或设置中存在业务链接时显示“进入流程”:
1. 未读通知先标记为已读。
2. `/problem_rectification_new/*` 地址去除应用 base 前缀后通过 Umi 路由打开,例如 `/problem_rectification_new/problem_rectification_flow_table?id=...` 转为 `/problem_rectification_flow_table?id=...`。
3. 只有 `/problem_rectification_new/*` 归入本应用路由;其他应用和外部 HTTP(S) 链接保留原地址,以新窗口打开并设置 `noopener,noreferrer`。
4. 已读请求失败时不执行跳转,防止通知状态和业务访问脱节。
通知正文始终按纯文本渲染,不执行服务端返回的 HTML,避免脚本注入或伪造交互控件。
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
import { calculateProblemSeverityLevel } from './problemSeverity'; import { calculateProblemSeverityLevel } from './problemSeverity';
import { import {
buildViewColumns, buildViewColumns,
buildViewSearchColumns,
checkViewCondition, checkViewCondition,
emptyValueForField, emptyValueForField,
formatFieldValue, formatFieldValue,
...@@ -1144,28 +1145,24 @@ export async function loadProblemRectificationViewCountMap({ filters = {}, views ...@@ -1144,28 +1145,24 @@ export async function loadProblemRectificationViewCountMap({ filters = {}, views
} }
export function buildGridViews(views = []) { export function buildGridViews(views = []) {
const gridViews = views const configuredGridViews = views.filter((view) => view.type === 'grid');
.filter((view) => view.type === 'grid') const gridViews = configuredGridViews.filter((view) => isViewAccessible(view));
.filter((view) => isViewAccessible(view));
if (gridViews.length > 0) { if (gridViews.length > 0 || configuredGridViews.length > 0) {
return gridViews; return gridViews;
} }
return [ const fallbackView = {
{
title: '全部', title: '全部',
type: 'grid', type: 'grid',
name: 'all', name: 'all',
settings: {}, settings: {},
}, };
]; return isViewAccessible(fallbackView) ? [fallbackView] : [];
} }
export function buildViewColumnsForList(view, subject) { export function buildViewColumnsForList(view, subject) {
return buildViewColumns(view, subject).filter( return buildViewColumns(view, subject);
(column) => column.field !== 'title' || !column.hide,
);
} }
export function getRecordCellText(record, column) { export function getRecordCellText(record, column) {
...@@ -1180,6 +1177,21 @@ export function getRecordCellText(record, column) { ...@@ -1180,6 +1177,21 @@ export function getRecordCellText(record, column) {
} }
export function buildProblemRectificationConfiguredListView(records, filters, gridView, subject) { export function buildProblemRectificationConfiguredListView(records, filters, gridView, subject) {
if (!gridView) {
return {
filteredRecords: [],
columns: [],
progressOptions: [],
auditStatusOptions: [],
severityOptions: [],
overallRatingOptions: [],
primaryResponsibleDepartmentOptions: [],
problemFromOptions: [],
rootCauseOptions: [],
focusOptions: [],
};
}
const nextKeyword = filters?.keyword || ''; const nextKeyword = filters?.keyword || '';
const nextInspectionReportMonth = normalizeText(filters?.inspectionReportMonth); const nextInspectionReportMonth = normalizeText(filters?.inspectionReportMonth);
const nextProgress = filters?.progress || ''; const nextProgress = filters?.progress || '';
...@@ -1192,7 +1204,8 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr ...@@ -1192,7 +1204,8 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
const nextFocusFlag = filters?.focusFlag || ''; const nextFocusFlag = filters?.focusFlag || '';
const columns = buildViewColumnsForList(gridView, subject); const columns = buildViewColumnsForList(gridView, subject);
const searchableFields = columns.map((column) => column.field); const searchColumns = buildViewSearchColumns(gridView, subject);
const searchableFields = searchColumns.map((column) => column.field);
const filteredRecords = records.filter((record) => { const filteredRecords = records.filter((record) => {
if ( if (
...@@ -1296,7 +1309,7 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr ...@@ -1296,7 +1309,7 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
const keyword = normalizeText(nextKeyword).toLowerCase(); const keyword = normalizeText(nextKeyword).toLowerCase();
return searchableFields return searchableFields
.map((fieldPath) => normalizeText(formatFieldValue(getFieldValue(rawRecord, fieldPath), columns.find((column) => column.field === fieldPath)?.fieldDef))) .map((fieldPath) => normalizeText(formatFieldValue(getFieldValue(rawRecord, fieldPath), searchColumns.find((column) => column.field === fieldPath)?.fieldDef)))
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
.includes(keyword); .includes(keyword);
......
...@@ -331,3 +331,49 @@ Query:`calculated=true|false` ...@@ -331,3 +331,49 @@ Query:`calculated=true|false`
3. 所有待建档材料创建成功后,再调用 `PUT /record/:name` 更新问题记录的 `metadata.rectification_proof_materials`,单值写对象,多值写对象数组。 3. 所有待建档材料创建成功后,再调用 `PUT /record/:name` 更新问题记录的 `metadata.rectification_proof_materials`,单值写对象,多值写对象数组。
4. 任一材料创建失败时不更新问题记录;一批材料中已创建成功的条目和“材料已创建、问题更新失败”的条目都在当前编辑会话中保留建档标记,重试保存只处理尚未建档的条目,不得重复创建。 4. 任一材料创建失败时不更新问题记录;一批材料中已创建成功的条目和“材料已创建、问题更新失败”的条目都在当前编辑会话中保留建档标记,重试保存只处理尚未建档的条目,不得重复创建。
5. 用户从草稿逐项删除本地附件后,该附件不再参与材料建档;取消详情编辑不创建 `am_material`。已经上传但未建档的二进制文件遵循上传服务自身的清理策略。 5. 用户从草稿逐项删除本地附件后,该附件不再参与材料建档;取消详情编辑不创建 `am_material`。已经上传但未建档的二进制文件遵循上传服务自身的清理策略。
## 6. 当前用户通知接口
通知中心使用平台通知接口,不使用 Record API,也不包裹 `metadata`。所有接口均要求当前登录用户身份。
### 6.1 分页查询
调用 `POST /notice/customer/query`:
```json
{
"page": 0,
"page_size": 10,
"filter": {
"category": "message",
"content": {
"$regex": "整改",
"$options": "i"
}
},
"status": "unread"
}
```
- `category` 为 `message` 或 `todo`。
- 内容搜索通过 `filter.content` 正则完成,前端必须转义用户输入中的正则特殊字符。
- 查询全部通知时 `status` 传 `null`;查询未读时传 `unread`,不得仅对当前页做前端未读过滤。
- 响应包含 `list`、`current_page`、`page_size`、`total_notices` 和 `total_pages`。
### 6.2 已读与未读数量
- `GET /notice/customer/count`:返回当前用户的未读通知数量,侧边栏角标和“全部已读”按钮以此为准。
- `PUT /notice/customer/read`,请求体 `{ "name": "通知 name" }`:标记单条通知已读。
- `PUT /notice/customer/read`,请求体 `{}`:标记当前用户全部通知已读。
### 6.3 待办动作
调用 `PUT /notice/customer/:action/action/:fname`,请求体固定传递通知记录 ID:
```json
{
"notice_id": "通知 _id"
}
```
其中 `:action`、`:fname` 和按钮标签均来自通知的 `actions` 配置,前端不得硬编码业务动作名称。执行成功后重新查询通知列表和未读数量;失败时保留当前列表并显示错误提示。
...@@ -524,3 +524,26 @@ type Record = { ...@@ -524,3 +524,26 @@ type Record = {
] ]
} }
``` ```
---
## 4. 当前用户通知对象
通知是平台 `/notice/customer/*` 接口返回的领域对象,不属于 Subject Record,因此不使用 `metadata` 封装。前端按以下结构消费:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `_id` | string | 通知记录 ID,执行待办动作时作为 `notice_id` |
| `name` | string | 通知业务标识,标记单条已读时使用 |
| `title` | string | 通知标题 |
| `category` | `message \| todo` | 消息或待办分类 |
| `content` | string | 通知正文;前端按纯文本安全渲染 |
| `content_compile` | string | 编译后正文,仅在 `content` 为空时作为降级内容 |
| `sent_at` | ISO 8601 string | 发送时间 |
| `from` | object | 发送人,优先读取 `display_name`,其次 `username` |
| `created_by` | object | 创建人;发送人缺失时作为降级来源 |
| `data.logs` | array | 用户已读日志;`user_id` 与当前用户 `_id` 匹配表示已读 |
| `settings` | object | 可选通知设置,可包含业务链接 |
| `actions` | array | 待办操作配置,包含按钮标签、动作名和方法名 |
通知内部业务链接可以位于 `settings.url`、`settings.link`、`data.url`、`data.link` 或正文中。指向 `/problem_rectification_new/*` 的站内地址进入 Umi 路由前应去除应用 base 前缀;其他应用和外部 HTTP(S) 地址使用新窗口打开。
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { AUTHORIZATION_APPLICATION_NAME } from '@/config/application';
export function normalizeText(value) { export function normalizeText(value) {
return String(value ?? '').trim(); return String(value ?? '').trim();
...@@ -34,62 +35,106 @@ export function getCurrentUserFromStorage() { ...@@ -34,62 +35,106 @@ export function getCurrentUserFromStorage() {
} }
} }
function collectUserGroups(user) { function collectIdentityValues(values) {
const directGroups = toArray(user?.groups) return toArray(values)
.flatMap((group) => [group?._id, group?.name, group?.display_name, group]) .flatMap((value) => {
if (value && typeof value === 'object') {
return [
value._id,
value.name,
value.display_name,
value.displayName,
value.username,
];
}
return value;
})
.map(normalizeText) .map(normalizeText)
.filter(Boolean); .filter(Boolean);
const appGroups = toArray(user?.applications).flatMap((app) => app?.groups || []);
return [...directGroups, ...appGroups].map(normalizeText).filter(Boolean);
} }
function collectUserRoles(user) { function getUserApplication(user, applicationName = AUTHORIZATION_APPLICATION_NAME) {
const directRoles = toArray(user?.roles); return toArray(user?.applications).find(
const appRoles = toArray(user?.applications).flatMap((app) => app?.roles || []); (application) => normalizeText(application?.name) === normalizeText(applicationName),
return [...directRoles, ...appRoles].map(normalizeText).filter(Boolean); ) || null;
} }
function collectUserPermissions(user) { function collectApplicationAuthorization(user, applicationName) {
const directPermissions = toArray(user?.permissions); const application = getUserApplication(user, applicationName);
const appPermissions = toArray(user?.applications).flatMap((app) => app?.permissions || []); return {
return [...directPermissions, ...appPermissions].map(normalizeText).filter(Boolean); groups: collectIdentityValues(application?.groups),
roles: collectIdentityValues(application?.roles),
permissions: collectIdentityValues(application?.permissions),
};
} }
function intersects(left, right) { function intersects(left, right) {
const rightSet = new Set(toArray(right).map(normalizeText).filter(Boolean)); const rightSet = new Set(collectIdentityValues(right));
return toArray(left).some((item) => rightSet.has(normalizeText(item))); return collectIdentityValues(left).some((item) => rightSet.has(item));
}
function getMultidimensionalTableAuthRule(authRule) {
return ['group', 'role', 'permission'].includes(authRule?.type)
? authRule
: '管理';
} }
export function hasAuthAccess(authRule, user = getCurrentUserFromStorage()) { export function isSystemAdministrator(user = getCurrentUserFromStorage()) {
if (!authRule) { return user?.type === 'admin' || user?.type === 'system';
}
export function hasApplicationManagementAccess(
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (isSystemAdministrator(user)) {
return true; return true;
} }
const { roles } = collectApplicationAuthorization(user, applicationName);
return intersects(roles, ['管理']);
}
export function hasAuthAccess(
authRule,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (!user) { if (!user) {
return false; return false;
} }
if (user.type === 'admin' || user.type === 'system') { if (hasApplicationManagementAccess(user, applicationName)) {
return true; return true;
} }
if (authRule.type === 'group') { const { groups, roles, permissions } = collectApplicationAuthorization(
return intersects(collectUserGroups(user), authRule.groups); user,
applicationName,
);
if (typeof authRule === 'string') {
return intersects(roles, [authRule]);
}
if (authRule?.type === 'group') {
return intersects(groups, authRule.groups);
} }
if (authRule.type === 'role') { if (authRule?.type === 'role') {
return intersects(collectUserRoles(user), authRule.roles); return intersects(roles, authRule.roles);
} }
if (authRule.type === 'permission') { if (authRule?.type === 'permission') {
return intersects(collectUserPermissions(user), authRule.permissions); return intersects(permissions, authRule.permissions);
} }
if (authRule.type === 'username') { if (authRule?.type === 'username') {
return intersects([user.username, user.display_name, user._id], authRule.usernames); return intersects([user.username], authRule.usernames);
} }
return intersects(collectUserRoles(user), ['管理']); return false;
} }
export function getFieldValue(record, fieldPath) { export function getFieldValue(record, fieldPath) {
...@@ -237,12 +282,21 @@ export function normalizeRecordLabel(value) { ...@@ -237,12 +282,21 @@ export function normalizeRecordLabel(value) {
return normalizeText(value); return normalizeText(value);
} }
export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) { export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
const configuredColumns = view?.settings?.columns || []; const configuredColumns = view?.settings?.columns || [];
const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view);
if (configuredColumns.length > 0) { if (configuredColumns.length > 0) {
return configuredColumns return configuredColumns
.filter((column) => includeHidden || !column.hide) .filter(
(column) =>
(includeHidden || !column.hide) &&
(!hideTitleColumn || column.field !== 'title'),
)
.map((column) => { .map((column) => {
const fieldDef = getFieldDefinition(column.field, subject); const fieldDef = getFieldDefinition(column.field, subject);
return { return {
...@@ -255,12 +309,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {}) ...@@ -255,12 +309,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
} }
const defaultColumns = []; const defaultColumns = [];
if (!hideTitleColumn) {
defaultColumns.push({ defaultColumns.push({
field: 'title', field: 'title',
width: 180, width: 180,
label: '标题', label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' }, fieldDef: { name: 'title', label: '标题', type: 'text' },
}); });
}
toArray(subject?.fields).forEach((field) => { toArray(subject?.fields).forEach((field) => {
defaultColumns.push({ defaultColumns.push({
...@@ -274,6 +330,23 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {}) ...@@ -274,6 +330,23 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
return defaultColumns; return defaultColumns;
} }
export function buildViewSearchColumns(view, subject) {
const displayColumns = buildViewColumns(view, subject);
if (displayColumns.some((column) => column.field === 'title')) {
return displayColumns;
}
return [
{
field: 'title',
width: 180,
label: '标题',
fieldDef: getFieldDefinition('title', subject),
},
...displayColumns,
];
}
function compareRuleValue(recordValue, comparator, expectedValue) { function compareRuleValue(recordValue, comparator, expectedValue) {
const expectedValues = toArray(expectedValue).map(normalizeText); const expectedValues = toArray(expectedValue).map(normalizeText);
const recordValues = toArray(recordValue).map((item) => { const recordValues = toArray(recordValue).map((item) => {
...@@ -332,18 +405,39 @@ export function checkViewCondition(condition, record) { ...@@ -332,18 +405,39 @@ export function checkViewCondition(condition, record) {
: results.every(Boolean); : results.every(Boolean);
} }
export function isViewAccessible(view, user = getCurrentUserFromStorage()) { export function isViewAccessible(
return hasAuthAccess(view?.settings?.viewAuth, user); view,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
return hasAuthAccess(
getMultidimensionalTableAuthRule(view?.settings?.viewAuth),
user,
applicationName,
);
} }
export function isFieldDisabledByView(fieldPath, view, user = getCurrentUserFromStorage()) { export function isFieldDisabledByView(
fieldPath,
view,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
const disabledColumn = toArray(view?.settings?.disableColumns).some((column) => column.field === fieldPath); const disabledColumn = toArray(view?.settings?.disableColumns).some((column) => column.field === fieldPath);
if (disabledColumn) { if (disabledColumn) {
return true; return true;
} }
if (hasApplicationManagementAccess(user, applicationName)) {
return false;
}
const fieldAuthRule = toArray(view?.settings?.auth).find((rule) => rule.field === fieldPath); const fieldAuthRule = toArray(view?.settings?.auth).find((rule) => rule.field === fieldPath);
return fieldAuthRule ? !hasAuthAccess(fieldAuthRule, user) : false; return !hasAuthAccess(
getMultidimensionalTableAuthRule(fieldAuthRule),
user,
applicationName,
);
} }
export function recordMatchesRestrictions(record, restrictions = []) { 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