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 = {
businessDashboard: '/integrity_risk_table/business_dashboard',
problemRectification: '/problem_rectification',
issueRectificationArchive: '/issue_rectification_archive',
notifications: '/notifications',
problemRectificationForm: '/problem_rectification_form',
problemRectificationFlowTable: '/problem_rectification_flow_table',
} as const;
......@@ -56,6 +57,10 @@ export default defineConfig({
path: ROUTE_PATHS.issueRectificationArchive,
component: './problem_rectification/archive/index',
},
{
path: ROUTE_PATHS.notifications,
component: './problem_rectification/notifications/NotificationListPage',
},
{
path: ROUTE_PATHS.problemRectificationForm,
component: './problem_rectification/form/index',
......
......@@ -106,6 +106,9 @@ Surface 用于表达页面内的包含关系,Overlay 用于表达浮动关系
## 6. 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。
- 固定列、最小列宽和横向滚动是数据可读性行为,不通过 CSS 改写 DataGrid 表头、行高、边框、背景或 Hover。
- DataGrid 无论列数多少都占满列表可用宽度。非宽表使用 HeroUI Pro 原生列尺寸:选择列和操作列保持固定,第一业务主列使用 `1fr` 吸收剩余空间,表格内容保持 `min-width: 100%`;只有真实最小列宽超过视口时才使用像素列宽和内部横向滚动,不允许出现表面全宽但正文只停在左侧的空白断层。
......
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
......@@ -62,6 +63,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);
......
......@@ -3,6 +3,7 @@ import xyLogoWhite from '@/assets/xy-logo-white.png';
import { APPLICATION_DISPLAY_NAME } from '@/config/application';
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { Heading } from 'react-aria-components';
import { AppGrid, LogOut } from './AppIcons';
function getUserDisplay(userInfo) {
......@@ -132,6 +133,11 @@ function SidebarContents({
<Icon size={16} />
</Sidebar.MenuIcon>
<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>
);
})}
......@@ -154,9 +160,9 @@ export default function DashboardSidebar(props) {
<SidebarContents {...props} />
</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}导航
</h2>
</Heading>
<SidebarContents {...props} />
</Sidebar.Mobile>
</>
......
......@@ -459,6 +459,7 @@ export default function RecordWorkbench({
onQuery,
title,
columns = EMPTY_ARRAY,
contextColumns = EMPTY_ARRAY,
columnGroups = EMPTY_ARRAY,
records = EMPTY_ARRAY,
selectedIds = EMPTY_ARRAY,
......@@ -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) => {
const stickyMeta = stickyColumns.stickyMeta[column.field];
const columnGroupId = columnGroupMeta.fieldToGroup[column.field];
......@@ -808,7 +830,7 @@ export default function RecordWorkbench({
: stickyMeta?.side === 'right'
? 'end'
: undefined,
isPrimary: columnIndex === 0,
isPrimary: contextColumns.length === 0 && columnIndex === 0,
render: (row) => {
if (row.isExpandedPanel) {
return columnIndex === 0 ? row.expandedContent : null;
......
export const APPLICATION_DISPLAY_NAME = '问题整改';
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 ROUTE_TITLES = Object.freeze({
......@@ -7,6 +8,7 @@ export const ROUTE_TITLES = Object.freeze({
'/integrity_risk_table/business_dashboard': '整改分析',
'/problem_rectification': '问题整改',
'/issue_rectification_archive': '往期材料归档',
'/notifications': '通知消息',
'/problem_rectification_form': '整改处理',
'/problem_rectification_flow_table': '整改流程',
});
......@@ -87,9 +87,9 @@ export default function SubjectViewContent({
const fieldType = field.fieldDef?.type || 'text';
const fieldLabel = getFieldLabel(field);
const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const fieldDisabled =
isFieldDisabled?.(field.field, view) ||
isFieldDisabledByView(field.field, view);
const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view)
: isFieldDisabledByView(field.field, view);
return (
<div
......
......@@ -20,9 +20,8 @@ import {
buildViewColumns,
checkViewCondition,
getFieldValue,
isFieldDisabledByView,
isViewAccessible,
setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
const EMPTY_ARRAY = [];
......@@ -121,7 +120,6 @@ const RecordDetailDrawer = (props, ref) => {
const filtered = (views || [])
.filter((view) => view.type === 'detail')
.filter((view) => (filterView.length === 0 ? true : filterView.includes(view.name)))
.filter((view) => isViewAccessible(view))
.filter((view) => checkViewCondition(view.settings?.visabled, remoteRecord));
if (filtered.length > 0) {
......@@ -169,6 +167,7 @@ const RecordDetailDrawer = (props, ref) => {
);
const activeView = detailViews.find((view) => view.name === activeTabKey) || detailViews[0];
const hideRecordTitle = shouldHideViewTitleColumn(activeView);
const isRecordLocked = remoteRecord?.metadata?.is_locked === true;
const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo(
......@@ -180,8 +179,7 @@ const RecordDetailDrawer = (props, ref) => {
);
const isDrawerFieldDisabled = (fieldPath, currentView) =>
disabledFieldSet.has(fieldPath) ||
isFieldDisabled(fieldPath, currentView) ||
isFieldDisabledByView(fieldPath, authorizationView);
isFieldDisabled(fieldPath, authorizationView || currentView);
const hasEditableFields =
Boolean(editableRecord) &&
......@@ -396,9 +394,11 @@ const RecordDetailDrawer = (props, ref) => {
<Modal.Header className="pr-16">
<Modal.Heading>{title || '问题整改详情'}</Modal.Heading>
<div className="truncate text-sm text-muted" title={recordTitle}>
{loading ? '正在读取记录信息' : recordTitle}
</div>
{!hideRecordTitle ? (
<div className="truncate text-sm text-muted" title={recordTitle}>
{loading ? '正在读取记录信息' : recordTitle}
</div>
) : null}
</Modal.Header>
{detailViews.length > 1 ? (
......
......@@ -6,10 +6,15 @@ import AppShell from '@/components/AppShell';
import {
Archive,
BarChart,
Bell,
CheckCircleIcon,
Project,
} from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/pages/problem_rectification/ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/pages/problem_rectification/notifications/notificationService';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react';
......@@ -40,6 +45,12 @@ const MENU_GROUPS = [
path: '/issue_rectification_archive',
icon: Archive,
},
{
name: '通知消息',
path: '/notifications',
icon: Bell,
showsNotificationCount: true,
},
],
},
];
......@@ -50,6 +61,21 @@ export default function ProblemRectificationLayout() {
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
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(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
......@@ -60,10 +86,33 @@ export default function ProblemRectificationLayout() {
document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [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 (
<>
<AppShell
menuGroups={MENU_GROUPS}
menuGroups={menuGroups}
onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
open={sidebarOpen}
......
import { countRecords, getSubjectView, loadRecord, loadSubject, queryRecords } from '@/services/DataService';
import {
buildViewColumns,
buildViewSearchColumns,
checkViewCondition,
formatFieldValue,
getFieldValue,
......@@ -29,17 +30,20 @@ export function normalizeArchiveBatchRecord(record) {
}
export async function loadArchiveWorkbenchResources() {
const [archiveRecordsResult, archiveSubject, archiveViews] = await Promise.all([
queryRecords({
subject: ISSUE_RECTIFICATION_ARCHIVE_SUBJECT,
page: 0,
page_size: 1000,
sort: '-updated_at -created_at',
mode: 'full',
}),
const [archiveSubject, archiveViews] = await Promise.all([
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,
page: 0,
page_size: 1000,
sort: '-updated_at -created_at',
mode: 'full',
})
: { records: [] };
const records = (archiveRecordsResult?.records || []).map(normalizeArchiveBatchRecord);
return {
......@@ -50,31 +54,37 @@ export async function loadArchiveWorkbenchResources() {
}
export function buildArchiveGridViews(views = []) {
const gridViews = views
.filter((view) => view.type === 'grid')
.filter((view) => isViewAccessible(view));
const configuredGridViews = views.filter((view) => view.type === 'grid');
const gridViews = configuredGridViews.filter((view) => isViewAccessible(view));
if (gridViews.length > 0) {
if (gridViews.length > 0 || configuredGridViews.length > 0) {
return gridViews;
}
return [
{
title: '全部批次',
type: 'grid',
name: 'all',
settings: {},
},
];
const fallbackView = {
title: '全部批次',
type: 'grid',
name: 'all',
settings: {},
};
return isViewAccessible(fallbackView) ? [fallbackView] : [];
}
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) {
if (!gridView) {
return {
filteredRecords: [],
columns: [],
};
}
const keyword = normalizeText(filters?.keyword).toLowerCase();
const columns = buildArchiveColumns(gridView, subject);
const searchColumns = buildViewSearchColumns(gridView, subject);
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
......@@ -98,7 +108,7 @@ export function buildArchiveConfiguredView(records, filters, gridView, subject)
return true;
}
const searchText = columns
const searchText = searchColumns
.map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef))
.join(' ')
.toLowerCase();
......
......@@ -51,6 +51,7 @@ import {
getFieldValue,
getSelectOptions,
normalizeText,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
import {
DEFAULT_PROBLEM_RECTIFICATION_PAGE_SIZE,
......@@ -895,6 +896,14 @@ export default function ProblemRectificationListPage() {
const reloadList = useCallback(
async (overrides = {}) => {
if (!subject || !activeGridView) {
setRecords([]);
setGridViewCounts({});
setPagination({
page: 0,
pageSize,
totalRecords: 0,
totalPages: 1,
});
return;
}
......@@ -1628,7 +1637,7 @@ export default function ProblemRectificationListPage() {
await reloadList();
setSuccessMessage(
isDepartmentFlow
? `推送成功,已生成 ${departmentAssignments.length} 条部门整改明细,可从记录标题右侧展开查看。`
? `推送成功,已生成 ${departmentAssignments.length} 条部门整改明细,可从问题动态或标题列展开查看。`
: `推送成功,已将 ${selectedRecords.length} 条问题的 ${Array.from(batchDepartmentItemsByRecordId.values()).flat().length} 条部门整改明细发送给填写人。`,
);
} catch (requestError) {
......@@ -1732,6 +1741,63 @@ export default function ProblemRectificationListPage() {
}}
title="问题清单"
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 || []}
records={filteredRecords}
selectedIds={selectedIds}
......@@ -1744,7 +1810,7 @@ export default function ProblemRectificationListPage() {
buildDepartmentDetailRecord(record, item, workflowUserLabels),
)
)}
toolbarActions={[
toolbarActions={activeGridView ? [
{
key: 'export',
label: '导出',
......@@ -1766,8 +1832,8 @@ export default function ProblemRectificationListPage() {
tone: 'primary',
onClick: () => setShowCreateModal(true),
},
]}
bulkActions={[
] : []}
bulkActions={activeGridView ? [
{
key: 'push',
label: '推送整改',
......@@ -1794,8 +1860,8 @@ export default function ProblemRectificationListPage() {
disabled: () => selectedIds.length === 0 || hasLockedSelection || actionLoading,
onClick: () => handleDelete(selectedRecords),
},
]}
rowActions={[
] : []}
rowActions={activeGridView ? [
{
key: 'view-detail',
label: '查看详情',
......@@ -1843,8 +1909,8 @@ export default function ProblemRectificationListPage() {
disabled: ({ record }) => actionLoading || record.isLocked,
onClick: ({ record }) => handleDelete([record]),
},
]}
detailPanel={{
] : []}
detailPanel={activeGridView ? {
getRecordName: (record) => record.name || record.id,
onDirtyClose: reloadList,
render: ({ open, recordName, onClose, onDataChange }) => (
......@@ -1875,15 +1941,17 @@ export default function ProblemRectificationListPage() {
useAuditMaterialCenter
/>
),
}}
} : null}
states={{
loading: loading || recordsLoading,
error,
success: '',
loadingTitle: '正在加载问题整改列表',
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: '当前视图暂无数据',
emptyDescription: '可以切换其他视图,或调整检查报告出具月份、严重程度、整改评级、主责部门和重点关注筛选。',
emptyTitle: activeGridView ? '当前视图暂无数据' : '暂无可访问的视图',
emptyDescription: activeGridView
? '可以切换其他视图,或调整检查报告出具月份、严重程度、整改评级、主责部门和重点关注筛选。'
: '当前账号未获得问题整改 Grid 视图的查看权限。',
}}
pagination={{
page,
......
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));
}
}
......@@ -13,6 +13,7 @@ import {
import { calculateProblemSeverityLevel } from './problemSeverity';
import {
buildViewColumns,
buildViewSearchColumns,
checkViewCondition,
emptyValueForField,
formatFieldValue,
......@@ -1144,28 +1145,24 @@ export async function loadProblemRectificationViewCountMap({ filters = {}, views
}
export function buildGridViews(views = []) {
const gridViews = views
.filter((view) => view.type === 'grid')
.filter((view) => isViewAccessible(view));
const configuredGridViews = views.filter((view) => view.type === 'grid');
const gridViews = configuredGridViews.filter((view) => isViewAccessible(view));
if (gridViews.length > 0) {
if (gridViews.length > 0 || configuredGridViews.length > 0) {
return gridViews;
}
return [
{
title: '全部',
type: 'grid',
name: 'all',
settings: {},
},
];
const fallbackView = {
title: '全部',
type: 'grid',
name: 'all',
settings: {},
};
return isViewAccessible(fallbackView) ? [fallbackView] : [];
}
export function buildViewColumnsForList(view, subject) {
return buildViewColumns(view, subject).filter(
(column) => column.field !== 'title' || !column.hide,
);
return buildViewColumns(view, subject);
}
export function getRecordCellText(record, column) {
......@@ -1180,6 +1177,21 @@ export function getRecordCellText(record, column) {
}
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 nextInspectionReportMonth = normalizeText(filters?.inspectionReportMonth);
const nextProgress = filters?.progress || '';
......@@ -1192,7 +1204,8 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
const nextFocusFlag = filters?.focusFlag || '';
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) => {
if (
......@@ -1296,7 +1309,7 @@ export function buildProblemRectificationConfiguredListView(records, filters, gr
const keyword = normalizeText(nextKeyword).toLowerCase();
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(' ')
.toLowerCase()
.includes(keyword);
......
......@@ -331,3 +331,49 @@ Query:`calculated=true|false`
3. 所有待建档材料创建成功后,再调用 `PUT /record/:name` 更新问题记录的 `metadata.rectification_proof_materials`,单值写对象,多值写对象数组。
4. 任一材料创建失败时不更新问题记录;一批材料中已创建成功的条目和“材料已创建、问题更新失败”的条目都在当前编辑会话中保留建档标记,重试保存只处理尚未建档的条目,不得重复创建。
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 = {
]
}
```
---
## 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 { AUTHORIZATION_APPLICATION_NAME } from '@/config/application';
export function normalizeText(value) {
return String(value ?? '').trim();
......@@ -34,62 +35,106 @@ export function getCurrentUserFromStorage() {
}
}
function collectUserGroups(user) {
const directGroups = toArray(user?.groups)
.flatMap((group) => [group?._id, group?.name, group?.display_name, group])
function collectIdentityValues(values) {
return toArray(values)
.flatMap((value) => {
if (value && typeof value === 'object') {
return [
value._id,
value.name,
value.display_name,
value.displayName,
value.username,
];
}
return value;
})
.map(normalizeText)
.filter(Boolean);
const appGroups = toArray(user?.applications).flatMap((app) => app?.groups || []);
return [...directGroups, ...appGroups].map(normalizeText).filter(Boolean);
}
function collectUserRoles(user) {
const directRoles = toArray(user?.roles);
const appRoles = toArray(user?.applications).flatMap((app) => app?.roles || []);
return [...directRoles, ...appRoles].map(normalizeText).filter(Boolean);
function getUserApplication(user, applicationName = AUTHORIZATION_APPLICATION_NAME) {
return toArray(user?.applications).find(
(application) => normalizeText(application?.name) === normalizeText(applicationName),
) || null;
}
function collectUserPermissions(user) {
const directPermissions = toArray(user?.permissions);
const appPermissions = toArray(user?.applications).flatMap((app) => app?.permissions || []);
return [...directPermissions, ...appPermissions].map(normalizeText).filter(Boolean);
function collectApplicationAuthorization(user, applicationName) {
const application = getUserApplication(user, applicationName);
return {
groups: collectIdentityValues(application?.groups),
roles: collectIdentityValues(application?.roles),
permissions: collectIdentityValues(application?.permissions),
};
}
function intersects(left, right) {
const rightSet = new Set(toArray(right).map(normalizeText).filter(Boolean));
return toArray(left).some((item) => rightSet.has(normalizeText(item)));
const rightSet = new Set(collectIdentityValues(right));
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()) {
if (!authRule) {
export function isSystemAdministrator(user = getCurrentUserFromStorage()) {
return user?.type === 'admin' || user?.type === 'system';
}
export function hasApplicationManagementAccess(
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (isSystemAdministrator(user)) {
return true;
}
const { roles } = collectApplicationAuthorization(user, applicationName);
return intersects(roles, ['管理']);
}
export function hasAuthAccess(
authRule,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (!user) {
return false;
}
if (user.type === 'admin' || user.type === 'system') {
if (hasApplicationManagementAccess(user, applicationName)) {
return true;
}
if (authRule.type === 'group') {
return intersects(collectUserGroups(user), authRule.groups);
const { groups, roles, permissions } = collectApplicationAuthorization(
user,
applicationName,
);
if (typeof authRule === 'string') {
return intersects(roles, [authRule]);
}
if (authRule?.type === 'group') {
return intersects(groups, authRule.groups);
}
if (authRule.type === 'role') {
return intersects(collectUserRoles(user), authRule.roles);
if (authRule?.type === 'role') {
return intersects(roles, authRule.roles);
}
if (authRule.type === 'permission') {
return intersects(collectUserPermissions(user), authRule.permissions);
if (authRule?.type === 'permission') {
return intersects(permissions, authRule.permissions);
}
if (authRule.type === 'username') {
return intersects([user.username, user.display_name, user._id], authRule.usernames);
if (authRule?.type === 'username') {
return intersects([user.username], authRule.usernames);
}
return intersects(collectUserRoles(user), ['管理']);
return false;
}
export function getFieldValue(record, fieldPath) {
......@@ -237,12 +282,21 @@ export function normalizeRecordLabel(value) {
return normalizeText(value);
}
export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
const configuredColumns = view?.settings?.columns || [];
const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view);
if (configuredColumns.length > 0) {
return configuredColumns
.filter((column) => includeHidden || !column.hide)
.filter(
(column) =>
(includeHidden || !column.hide) &&
(!hideTitleColumn || column.field !== 'title'),
)
.map((column) => {
const fieldDef = getFieldDefinition(column.field, subject);
return {
......@@ -255,12 +309,14 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
}
const defaultColumns = [];
defaultColumns.push({
field: 'title',
width: 180,
label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' },
});
if (!hideTitleColumn) {
defaultColumns.push({
field: 'title',
width: 180,
label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' },
});
}
toArray(subject?.fields).forEach((field) => {
defaultColumns.push({
......@@ -274,6 +330,23 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
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) {
const expectedValues = toArray(expectedValue).map(normalizeText);
const recordValues = toArray(recordValue).map((item) => {
......@@ -332,18 +405,39 @@ export function checkViewCondition(condition, record) {
: results.every(Boolean);
}
export function isViewAccessible(view, user = getCurrentUserFromStorage()) {
return hasAuthAccess(view?.settings?.viewAuth, user);
}
export function isFieldDisabledByView(fieldPath, view, user = getCurrentUserFromStorage()) {
export function isViewAccessible(
view,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
return hasAuthAccess(
getMultidimensionalTableAuthRule(view?.settings?.viewAuth),
user,
applicationName,
);
}
export function isFieldDisabledByView(
fieldPath,
view,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
const disabledColumn = toArray(view?.settings?.disableColumns).some((column) => column.field === fieldPath);
if (disabledColumn) {
return true;
}
if (hasApplicationManagementAccess(user, applicationName)) {
return false;
}
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 = []) {
......
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