Commit fd8348b3 authored by Andy-bubu's avatar Andy-bubu

feat: add cross-application record features

parent f8a83c57
......@@ -12,6 +12,7 @@ const ROUTE_PATHS = {
auditProjectArchive: '/audit_project_archive',
projectForm: '/project_form',
auditProjectForm: '/audit_project_form',
notifications: '/notifications',
} as const;
const APP_BASE_PATH = '/audit_project_manager_new/';
......@@ -65,6 +66,10 @@ export default defineConfig({
path: ROUTE_PATHS.auditProjectForm,
component: './project_manager_xy/AuditProjectFormPage',
},
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
],
},
],
......
......@@ -135,3 +135,30 @@ CSS,并用显式 `@source` 仅扫描入口、组件、页面和 wrapper;`src
动态表单缺少字段定义时不从数据对象 key 猜测表头,改为 Pro EmptyState,避免把内部字段
标识或存储结构作为业务界面。
关联记录在缺少 Subject 字段定义时同样只保留标题列,不从 `metadata` key 生成未知表头。
## 跨应用功能数据流
```text
UserContext
-> user.applications[name = audit_project_manager]
-> Grid viewAuth / field auth(Detail View 只负责编排与 shortTitle)
Navbar notification badge
-> platform notice APIs -> /notifications -> base-aware internal navigation
Editable file field
-> material-center selection -> file snapshot in detail draft
-> local /upload -> pending material draft
-> detail Save -> create am_material (idempotent retry marker)
-> all created -> existing record update callback
```
Subject/View 资源必须先加载并完成 Grid View 授权过滤;只有存在可访问 View 才允许查询记录。
`RecordDetailDrawer` 接收激活 Grid View 作为唯一 `authorizationView`,激活 Detail View 只决定字段
布局、条件和记录标题是否显示。通知与材料服务只封装既有接口参数,不拥有 Shell、记录更新或
业务状态流。
通知路由解析以 `ROUTE_TITLES` 的真实注册路径为本应用站内白名单,并只剥离
`/audit_project_manager_new` 自身 base;不能把其它应用的裸路径交给当前 Umi Router。
`FileUploadField` 直接组合 HeroUI Pro `DropZone.Input`,将运行时 `multiple` 同时应用于点击选择和
拖放;材料字段继续负责单值替换、多值追加、URL 去重及详情编辑会话的 pending 建档状态。
......@@ -93,6 +93,19 @@
- [x] 四份项目 UI 文档更新为最终事实
- [x] 未提交、未推送、未合并
## 2026-08-26 五项功能
- [x] Grid `shortTitle` 按激活 View 生效,title 仍参与搜索且业务入口保留
- [x] Detail `shortTitle` 随激活 Detail View 即时更新
- [x] 权限只读取 `audit_project_manager` 应用项,缺省拒绝且管理旁路正确
- [x] 无可访问 Grid View 时不查询记录、不建 fallback、不显示新增/批量/行操作
- [x] 只有一个可访问 Grid View 时隐藏 Tabs 轨道
- [x] `/notifications`、Navbar Badge、消息/待办、全部/未读和动态操作完成
- [x] 通知未读 0/1/99/100、站内外跳转、纯文本正文完成浏览器验收
- [x] 所有 editable file 字段支持材料中心和本地上传
- [x] 单值/多值、材料选择、URL 去重、取消、失败与重试事务完成浏览器验收
- [x] 四视口浅深色完成;最终构建、审计脚本、定向扫描和干净控制台复验通过
说明:扩展的全项目 `npm ls ... react react-dom` 会报告两个 Pro peer 包仍声明 React 18
peer,以及 Umi 工具链锁定的 React 18 子图;应用与 HeroUI/React Aria 子图实际统一解析
React 19.2.8,构建和浏览器验收通过。详见 `docs/xinyuan-ui-audit.md`。
......@@ -92,3 +92,17 @@ Inter 与系统回退字体之间切换。
Portal、错误/空/加载、键盘 focus 和移动响应式。合入前运行 `npm run build`、
`git diff --check`、鑫元审计脚本、旧组件/私有视觉/本机路径扫描、依赖树和 Pro 子路径检查;
不得以构建成功替代浏览器验收。
## 配置化权限、通知与材料
- `settings.shortTitle === true` 只隐藏当前 View 的 `title` 展示;title 数据、关键词搜索、排序、
保存与详情入口不得丢失。Detail 标题显示只跟随当前激活 Detail View。
- View/字段权限只能读取正式应用 `audit_project_manager` 在 `user.applications[]` 中的授权项。
系统管理员及该应用“管理”角色可旁路;普通用户缺失配置时默认拒绝,`disableColumns` 不旁路。
- Navbar 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,位于主题按钮左侧;通知页面
使用原平台接口,正文按纯文本呈现,站内路径剥离 `/audit_project_manager_new` 后交给 Umi。
- editable file 字段统一提供“材料中心 / 本地上传”。选择材料只写文件快照;本地文件先上传到
详情草稿,保存时逐个创建 `am_material`,全部成功后才能更新业务记录,失败重试不得重复建档。
- 材料来源切换使用内容宽度 HeroUI `Tabs` 的 `secondary` variant;选择材料使用 Overlay 内
`secondary` Autocomplete,本地上传使用 HeroUI Pro `DropZone`。运行时 `multiple` 必须传给
`DropZone.Input` 和拖放处理,不能只在业务层把返回值当数组。
......@@ -7,9 +7,9 @@
- 分支:`codex/ui-ux-xinyuan-migration`
- 唯一实现基准:`problem_rectification`
- 应用基路径:`/audit_project_manager_new/`
- 本轮启动命令:`npm run dev -- --port 8014`(Umi 自动选择实际空闲端口 `8005`)
- 目标验收地址:`http://127.0.0.1:8005/audit_project_manager_new/`
- 基准验收地址:`http://127.0.0.1:8006/problem_rectification_new/problem_rectification`
- 本轮启动命令:`npm run dev -- --port 8000`
- 目标验收地址:`http://127.0.0.1:8000/audit_project_manager_new/`
- 基准验收地址:`http://127.0.0.1:8001/problem_rectification_new/`
迁移前已启动未修改项目并记录业务基线。原登录页为蓝色营销式左右分栏,Shell、表格、
弹窗和表单混用 Catalyst、Headless UI、Heroicons、ECharts 与可见原生控件。浏览器检查的
......@@ -258,6 +258,43 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
`oklch(1 0.0022 27.03)`、`oklch(0.9524 0.0036 27.03)`、
`oklch(0.9373 0.0036 27.03)`、L2、L1;深色对应 `0.2103`、`0.257`、`0.2721`、L2、L1。
- 目标与“问题整改”同时打开对照,所有 Tabs 均由 HeroUI 官方 variant 决定外观。
## 2026-08-26 跨应用五项功能审计
- 功能白名单判定:本应用为“审计项目管理”,属于完整功能应用,实施 Grid/Detail
`shortTitle`、视图权限、消息通知、文件与审计材料中心五项功能。
- 运行标识:Subject 为 `audit_project`,正式应用授权名与材料 `source_app` 为
`audit_project_manager`;Umi 路由 base 为 `/audit_project_manager_new/`。站点配置同时存在
演示入口 `audit_project_manager_new`,但它不作为权限或材料来源标识。
- 登录样本:Andy 的 `type` 为 `admin`、`applications` 为空,只能验证系统管理员旁路;普通
用户必须仅从 `user.applications[]` 中名称为 `audit_project_manager` 的项目读取 group、role
和 permission,禁止合并顶层或其他应用授权。
- View 现状:七个 Grid View 均配置 `shortTitle: true`;“计划”额外配置 group
`viewAuth` 与字段 `auth`。页面此前通过常量无条件隐藏 `title`,搜索也随之丢失 title;
View 权限缺省允许、无权时创建 fallback,并在鉴权前查询数据,均不符合基准。
- Detail 现状:详情视图未统一配置 `shortTitle`;抽屉始终显示记录标题,字段权限同时受 Detail
View 影响。目标行为应随当前激活 Detail View 即时切换标题显示,但字段权限只读取激活 Grid
View,`disableColumns` 永远只读。
- 通知现状:原 `DataService` 已提供 `queryUserNotice`、`readUserNotice`、
`unReadNoticeCount` 和 `exacteActionFunction`,但没有独立页面、路由或 Navbar 入口。
- 文件现状:editable file 字段只执行 `/upload` 并立即写入详情草稿,没有材料中心选择,也没有
“保存详情时建档”的事务边界。材料 Subject 为 `am_material`,快照协议必须保持
`{name,type,size,url}`,不得保存材料 ID。
### 本轮浏览器验收记录
- Andy 管理员、目标应用“管理”角色、普通用户、无权限用户及单 View 用户均通过浏览器响应夹具
验证;无权限状态没有 `/record/query`,也没有新增、批量、行操作或兜底 View。
- Grid `shortTitle` 的 true/false 和 Detail View 动态切换均已验证;隐藏 title 后项目名称和固定
操作列仍可打开详情,关键词搜索仍包含记录 title。
- 通知验证 0/1/99/100(100 显示 `99+`)、纯文本正文、`status = "unread"`、正则搜索转义、
单条/全部已读、动态 action、站内 `/project_form` 跳转和外部无 opener 新窗口。
- 材料验证单值保存顺序、两文件多值选择、已有材料 URL 去重、取消不建档及部分失败重试;失败后
第一个成功材料未重复创建,全部材料成功后才出现业务记录 PUT。所有夹具只拦截浏览器请求,
没有向真实后端写测试数据。
- 目标与“问题整改”同时打开,在 1440x900、1280x720、768x1024、390x844 的浅色和深色检查
通知页、列表、详情及材料控件;Navbar 动作均为 36px 且坐标一致,页面横向溢出为零。390px
详情面板为 358x812,四角、内部滚动、Tabs、Autocomplete 和 DropZone 在两种主题下可辨。
1440x900、1280x720、768x1024、390x844 浅色及相关深色状态均无页面横向溢出;390px
长分组只在官方 ScrollShadow 内滚动。点击“标准化业务制度修订情况”后仅该分组选中,表格
正确切换为“问题一 / 符合 / 王五”,控制台 0 errors / 0 warnings。
......@@ -282,6 +319,80 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
- Auth/Site 路由加载态删除 `text-slate-500`,改用 HeroUI Spinner;详情全屏图标统一收口到
`AppIcons`,页面直接导入图标包归零。
### 2026-08-26 最终硬门槛复核
以下命令均在 `/Users/liuchenlong/Desktop/job/audit_project_manager` 执行:
```bash
npm run build
git diff --check
bash /Users/liuchenlong/.codex/skills/xinyuan-design-system/scripts/audit_project.sh \
/Users/liuchenlong/Desktop/job/audit_project_manager
cmp src/design-system/theme.css \
/Users/liuchenlong/.codex/skills/xinyuan-design-system/assets/baseline/design-system/theme.css
npm ls @heroui-pro/react @heroui/react @heroui/styles \
react-aria-components @react-aria/utils --depth=1
```
结果:Umi/Webpack 在 6.86 秒内编译成功;`git diff --check`、鑫元审计脚本、权威主题
字节比较与定向依赖树均以退出码 0 完成。依赖树解析为 `@heroui-pro/react@1.0.0-beta.8`、
`@heroui/react@3.2.4`、`@heroui/styles@3.2.4`、`react-aria-components@1.20.0` 和
`@react-aria/utils@3.34.1`。
HeroUI Pro 使用 Node ESM 动态 `import()` 检查根包及以下子路径:
```bash
node --input-type=module <<'NODE'
import fs from 'node:fs';
import {createRequire} from 'node:module';
const require = createRequire(import.meta.url);
const packagePath = require.resolve('@heroui-pro/react/package.json');
const packageDirectory = packagePath.replace(/package\.json$/, '');
for (const relativePath of ['dist/index.js', 'dist/css/index.css']) {
if (!fs.existsSync(`${packageDirectory}${relativePath}`)) process.exitCode = 1;
}
for (const specifier of [
'@heroui-pro/react',
'@heroui-pro/react/sidebar',
'@heroui-pro/react/navbar',
'@heroui-pro/react/data-grid',
'@heroui-pro/react/drop-zone',
'@heroui-pro/react/action-bar',
'@heroui-pro/react/empty-state',
'@heroui-pro/react/list-view',
]) await import(specifier);
console.log(import.meta.resolve('@heroui-pro/react/css'));
NODE
```
根包与八个 JS 子路径全部成功;CSS export 解析到本仓库 pnpm virtual store 内的
`dist/css/index.css`,`dist/index.js` 和 `dist/css/index.css` 均存在。审计脚本自身仍会对六个
ESM-only 子路径打印 CommonJS `require` WARN,但其退出码为 0,且上述 ESM 检查逐项通过。
```bash
rg -n 'catalyst-ui-kit|@headlessui/react|@heroicons' src --glob '*.{js,jsx,ts,tsx}'
rg -n '/compat/|\\\\compat\\\\' src --glob '*.{js,jsx,ts,tsx}'
rg -n "<(select|datalist)\\b|<input[^>]+type=['\"](date|datetime-local|file)['\"]" \
src --glob '*.{js,jsx,ts,tsx}'
rg -n "className=.*(bg-|border|rounded|shadow|ring|outline|hover:|focus:|active:)" \
src/pages src/components --glob '*.{js,jsx,ts,tsx}'
rg -n "sky-|blue-|cyan-|rose-|pink-|zinc-|slate-|bg-gradient|#[0-9A-Fa-f]{6}" \
src/pages --glob '*.{js,jsx,ts,tsx,css,less,scss}'
find src/pages -type f \( -name '*.css' -o -name '*.less' -o -name '*.scss' \) -print
rg -n "file:(\\.\\.?/|/Users/)|/Users/.+/node_modules|project_manager_new/node_modules" \
package.json pnpm-lock.yaml pnpm-workspace.yaml
rg -n 'hp_[A-Za-z0-9]+' . --glob '!node_modules/**' --glob '!dist/**' --glob '!.git/**'
```
结果:以上旧组件、compat、可见原生选择/日期/文件控件、页面私有视觉 utility、页面旧色调、
页面私有样式文件、`file:`/本机依赖路径及授权字面值扫描均为 0 命中。
最终浏览器控制台使用 `$playwright-cli` 在既有目标/基准对照 Context 中新建页面,先绑定
`console` 与 `pageerror` 监听器,再依次访问 `/audit_project` 和 `/notifications`。两个页面均
返回 200,标题正确,页面级横向溢出均为 0,捕获结果为 `0 errors / 0 warnings`;此前为验证
材料失败重试而故意模拟的 500 日志未计入本次干净结果。
### 2026-08-24 DataGrid 长文本边界复核
- 全局三个 HeroUI Pro DataGrid 消费入口均复用 `DataTable`:主业务列表、动态表单数据表和
......
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
......@@ -65,6 +66,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);
......
......@@ -20,6 +20,8 @@ function getInitialTheme() {
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
title,
......@@ -52,6 +54,8 @@ export default function AppShell({
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
theme={theme}
title={title}
onThemeChange={setTheme}
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -20,6 +27,32 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label="通知消息"
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={onOpenNotifications}
>
<Bell size={16} />
</Button>
{normalizedNotificationCount > 0 ? (
<Badge
aria-label={`${normalizedNotificationCount}条未读通知`}
color="danger"
size="sm"
>
{normalizedNotificationCount > 99 ? '99+' : normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
......
......@@ -9,6 +9,7 @@ export default function FileUploadField({
description = '将文件拖到此处,或从本地选择。',
isDisabled = false,
fileName = '',
multiple = false,
onFilesChange,
triggerLabel = '选择文件',
}) {
......@@ -29,9 +30,9 @@ export default function FileUploadField({
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(files.slice(0, 1));
emitFiles(multiple ? files : files.slice(0, 1));
},
[emitFiles],
[emitFiles, multiple],
);
return (
......@@ -44,6 +45,7 @@ export default function FileUploadField({
<DropZone.Input
accept={accept}
disabled={isDisabled}
multiple={multiple}
onSelect={emitFiles}
/>
<DropZone.Icon>
......@@ -51,7 +53,9 @@ export default function FileUploadField({
</DropZone.Icon>
<DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>
{fileName ? '已选择文件,可重新选择进行替换。' : description}
{fileName
? `已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
: description}
</DropZone.Description>
<DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" />
......
......@@ -104,6 +104,7 @@ export default function SubjectRecordWorkbench({
() => gridViews.find((view) => view.name === activeViewName) || gridViews[0] || null,
[gridViews, activeViewName],
);
const hasAccessibleView = Boolean(activeView);
const configuredView = useMemo(
() => buildConfiguredWorkbenchView(records, filters, activeView, subject),
[records, filters, activeView, subject],
......@@ -219,7 +220,7 @@ export default function SubjectRecordWorkbench({
{ label: '当前已选', value: selectedIds.length, hint: '可执行批量操作', tone: 'amber' },
];
const builtToolbarActions = [
const builtToolbarActions = hasAccessibleView ? [
...toolbarActions.map(wrapAction),
...(allowCreate && !readonly
? [{
......@@ -234,9 +235,9 @@ export default function SubjectRecordWorkbench({
},
}]
: []),
];
] : [];
const builtBulkActions = [
const builtBulkActions = hasAccessibleView ? [
...(allowDelete && !readonly
? [{
key: 'bulk-delete',
......@@ -251,9 +252,9 @@ export default function SubjectRecordWorkbench({
),
}]
: []),
];
] : [];
const builtRowActions = [
const builtRowActions = hasAccessibleView ? [
{
key: 'view-detail',
label: '查看详情',
......@@ -292,7 +293,7 @@ export default function SubjectRecordWorkbench({
onClick: ({ record }) => setDeleteTargets([record.raw || record]),
}]
: []),
];
] : [];
return (
<>
......@@ -322,7 +323,7 @@ export default function SubjectRecordWorkbench({
toolbarActions={builtToolbarActions}
bulkActions={builtBulkActions}
rowActions={builtRowActions}
detailPanel={{
detailPanel={hasAccessibleView ? {
getRecordName: (record) => record.name || record.id,
onDirtyClose: reload,
render: ({ open, recordName, onClose, onDataChange }) => (
......@@ -334,17 +335,20 @@ export default function SubjectRecordWorkbench({
onClose={onClose}
onDataChange={onDataChange}
preferredViewName={activeView?.title || activeView?.name}
authorizationView={activeView}
/>
),
}}
} : null}
states={{
loading,
error,
success,
loadingTitle: `正在加载${title}`,
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: '当前视图暂无数据',
emptyDescription: emptyDescription || '可以切换其他视图或调整筛选条件。',
emptyTitle: hasAccessibleView ? '当前视图暂无数据' : '暂无可访问的视图',
emptyDescription: hasAccessibleView
? emptyDescription || '可以切换其他视图或调整筛选条件。'
: '当前账号未获得该对象 Grid 视图的查看权限。',
}}
pagination={{
page: safePage,
......
export const APPLICATION_DISPLAY_NAME = '审计项目管理';
export const APPLICATION_BASE_PATH = '/audit_project_manager_new/';
export const AUTHORIZATION_APPLICATION_NAME = 'audit_project_manager';
export const SOURCE_APPLICATION_NAME = 'audit_project_manager';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
......@@ -8,4 +10,5 @@ export const ROUTE_TITLES = Object.freeze({
'/audit_project_archive': '往期项目归档',
'/project_form': '项目表单',
'/audit_project_form': '审计项目填写表',
'/notifications': '通知消息',
});
......@@ -54,13 +54,15 @@ export default function SubjectViewContent({
editSessionId,
mode = 'view',
onFieldChange,
onPendingLocalMaterialChange,
onFieldValidation,
readonly,
isFieldDisabled,
minimal = false,
useAuditMaterialCenter = false,
}) {
const displayFields = buildViewColumns(view, subject);
const contentFields = mode === 'view' || minimal
const contentFields = minimal
? (displayFields || []).filter((field) => field.field !== 'title')
: displayFields;
const fieldVariant = 'secondary';
......@@ -117,8 +119,10 @@ export default function SubjectViewContent({
)
}
onValidationChange={onFieldValidation}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
readonly={readonly}
disabled={fieldDisabled}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
</div>
</>
......
......@@ -17,6 +17,7 @@ import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import DynamicFormValueEditor from '@/components/DynamicFormValueEditor';
import AuditProjectMaterialField from '@/pages/project_manager_xy/components/AuditProjectMaterialField';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import {
dateInputValue,
......@@ -315,7 +316,9 @@ function ControlledFieldEditor({
fieldVariant,
placeholder,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
useAuditMaterialCenter,
}) {
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
......@@ -402,6 +405,20 @@ function ControlledFieldEditor({
}
if (fieldType === 'file') {
if (useAuditMaterialCenter) {
return (
<AuditProjectMaterialField
editSessionId={editSessionId}
fieldPath={fieldPath}
multiple={Boolean(fieldDef?.multiple)}
onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange}
value={value}
/>
);
}
return (
<Card variant="secondary">
<Card.Content className="space-y-3">
......@@ -548,7 +565,9 @@ export default function TailwindEditableField({
placeholder,
onSave,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
useAuditMaterialCenter = false,
mode = 'inline',
readonly = false,
disabled = false,
......@@ -591,7 +610,9 @@ export default function TailwindEditableField({
fieldVariant={fieldVariant}
placeholder={textPlaceholder}
onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
);
}
......
......@@ -2,6 +2,7 @@ import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip, toast } from '@heroui/react';
import React, {
useCallback,
useContext,
forwardRef,
useEffect,
useImperativeHandle,
......@@ -11,13 +12,15 @@ import React, {
} from 'react';
import { useSubjectRecord } from './hooks/useSubjectRecord';
import SubjectViewContent from './components/SubjectViewContent';
import { UserContext } from '@/wrapper/Auth';
import { createAuditProjectMaterial } from '@/pages/project_manager_xy/auditProjectMaterialService';
import {
buildViewColumns,
checkViewCondition,
getFieldValue,
isFieldDisabledByView,
isViewAccessible,
setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
const EMPTY_ARRAY = [];
......@@ -99,8 +102,11 @@ const RecordDetailDrawer = (props, ref) => {
footerActions = EMPTY_ARRAY,
autoSwitchToLastTab = false,
isViewDisabled: isViewDisabledProp,
useAuditMaterialCenter = false,
} = props;
const currentUser = useContext(UserContext);
const [refreshKey, setRefreshKey] = useState(() => Date.now());
const [isFullscreen, setIsFullscreen] = useState(false);
const [activeTabKey, setActiveTabKey] = useState();
......@@ -114,6 +120,7 @@ const RecordDetailDrawer = (props, ref) => {
const [actionLoadingKey, setActionLoadingKey] = useState('');
const [lastTabSwitchRevision, setLastTabSwitchRevision] = useState('');
const editSessionRef = useRef(0);
const pendingLocalMaterialsRef = useRef({});
const {
editableRecord,
......@@ -147,6 +154,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors({});
setSaveError('');
setLastTabSwitchRevision('');
pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1;
}, [open, recordName]);
......@@ -160,7 +168,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) {
......@@ -248,6 +255,7 @@ const RecordDetailDrawer = (props, ref) => {
const activeView = enabledDetailViews.find((view) => view.name === activeTabKey)
|| enabledDetailViews[enabledDetailViews.length - 1]
|| detailViews[0];
const hideRecordTitle = shouldHideViewTitleColumn(activeView);
const isRecordLocked = remoteRecord?.metadata?.is_locked === true;
const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo(
......@@ -330,6 +338,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
};
const handleRequestClose = () => {
......@@ -345,6 +354,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
setIsEditing(true);
};
......@@ -387,6 +397,35 @@ const RecordDetailDrawer = (props, ref) => {
});
}, []);
const handlePendingLocalMaterialChange = useCallback((fieldPath, change, editSessionId) => {
if (editSessionId !== editSessionRef.current) return;
const nextPendingMaterials = { ...pendingLocalMaterialsRef.current };
const currentFieldMaterials = Array.isArray(nextPendingMaterials[fieldPath])
? nextPendingMaterials[fieldPath]
: [];
let nextFieldMaterials = currentFieldMaterials;
if (change?.type === 'replace') {
nextFieldMaterials = change.materials || [];
} else if (change?.type === 'add') {
const materialsById = new Map(
currentFieldMaterials.map((material) => [material.clientId, material]),
);
(change.materials || []).forEach((material) => {
materialsById.set(material.clientId, material);
});
nextFieldMaterials = [...materialsById.values()];
} else if (change?.type === 'remove') {
nextFieldMaterials = currentFieldMaterials.filter(
(material) => material.clientId !== change.clientId,
);
}
if (nextFieldMaterials.length > 0) nextPendingMaterials[fieldPath] = nextFieldMaterials;
else delete nextPendingMaterials[fieldPath];
pendingLocalMaterialsRef.current = nextPendingMaterials;
}, []);
const handleSave = async () => {
if (!hasDirtyFields || hasValidationErrors || isSaving) return;
......@@ -394,10 +433,31 @@ const RecordDetailDrawer = (props, ref) => {
setSaveError('');
try {
const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current)
.filter(([fieldPath]) => dirtyFields[fieldPath]);
for (const [fieldPath, fieldMaterials] of pendingMaterials) {
for (const pendingMaterial of fieldMaterials) {
if (pendingMaterial.materialRecordId) continue;
const createdMaterial = await createAuditProjectMaterial({
fileAsset: pendingMaterial.fileAsset,
currentUser,
});
const currentFieldMaterials = pendingLocalMaterialsRef.current[fieldPath] || [];
pendingLocalMaterialsRef.current = {
...pendingLocalMaterialsRef.current,
[fieldPath]: currentFieldMaterials.map((material) =>
material.clientId === pendingMaterial.clientId
? { ...material, materialRecordId: createdMaterial.id }
: material),
};
}
}
await handleFieldsUpdate(Object.values(dirtyFields));
setIsEditing(false);
setDirtyFields({});
setValidationErrors({});
pendingLocalMaterialsRef.current = {};
onDataChange?.(true);
} catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
......@@ -412,7 +472,7 @@ const RecordDetailDrawer = (props, ref) => {
displayedRecord?.name ||
recordName ||
'记录';
const showRecordTitle = loading || !isPlaceholderTitle(recordTitle);
const showRecordTitle = !hideRecordTitle && (loading || !isPlaceholderTitle(recordTitle));
return (
<Modal.Root>
......@@ -547,9 +607,11 @@ const RecordDetailDrawer = (props, ref) => {
mode={isEditing ? 'edit' : 'view'}
onFieldChange={handleDraftFieldChange}
onFieldValidation={handleFieldValidation}
onPendingLocalMaterialChange={handlePendingLocalMaterialChange}
readonly={effectiveReadonly}
isFieldDisabled={isDrawerFieldDisabled}
minimal={minimalContent}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
</div>
)}
......
......@@ -12,8 +12,6 @@ import {
transitionAuditProject,
} from './AuditProjectService';
const HIDDEN_LIST_COLUMNS = ['title'];
function hasStatus(record, statuses) {
return statuses.includes(record?.metadata?.project_status);
}
......@@ -92,7 +90,6 @@ export default function AuditProjectListPage() {
description="按线上视图配置展示审计项目,支持详情维护、状态流转、导出和批量归档。"
allowEdit={false}
allowClone={false}
hiddenColumnFields={HIDDEN_LIST_COLUMNS}
buildSummaryItems={() => []}
toolbarActions={[
{
......@@ -118,6 +115,7 @@ export default function AuditProjectListPage() {
headerActions: HEADER_ACTIONS,
footerActions: FOOTER_ACTIONS,
autoSwitchToLastTab: true,
useAuditMaterialCenter: true,
}}
emptyDescription="当前视图暂无审计项目,可以切换视图、清空筛选或新建记录。"
/>
......
......@@ -10,6 +10,10 @@ import {
Project,
} from '@/components/AppIcons';
import ApplicationSwitcherDialog from './ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from './notifications/notificationService';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react';
......@@ -50,6 +54,7 @@ export default function ProjectManagerLayout() {
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const currentTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
......@@ -60,10 +65,33 @@ export default function ProjectManagerLayout() {
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}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
open={sidebarOpen}
......
......@@ -5,6 +5,7 @@ import {
} from '@/services/DataService';
import {
buildViewColumns,
buildViewSearchColumns,
checkViewCondition,
formatFieldValue,
getFieldValue,
......@@ -56,11 +57,14 @@ export async function queryAllSubjectRecords(subjectName, query = {}) {
}
export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
const [records, subject, views] = await Promise.all([
queryAllSubjectRecords(subjectName, query),
const [subject, views] = await Promise.all([
loadSubject(subjectName),
getSubjectView(subjectName),
]);
const accessibleGridViews = buildGridViews(views || []);
const records = accessibleGridViews.length > 0
? await queryAllSubjectRecords(subjectName, query)
: [];
return {
records: records.map(normalizeWorkbenchRecord),
......@@ -70,29 +74,25 @@ export async function loadSubjectWorkbenchResources(subjectName, query = {}) {
}
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 buildConfiguredWorkbenchView(records, filters, view, subject) {
if (!view) return { columns: [], filteredRecords: [] };
const keyword = normalizeText(filters?.keyword).toLowerCase();
const status = normalizeText(filters?.status);
const year = normalizeText(filters?.year);
const columns = buildViewColumns(view, subject);
const searchColumns = buildViewSearchColumns(view, subject);
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
const metadata = rawRecord?.metadata || {};
......@@ -125,7 +125,7 @@ export function buildConfiguredWorkbenchView(records, filters, view, subject) {
return true;
}
const searchText = columns
const searchText = searchColumns
.map((column) => formatFieldValue(getFieldValue(rawRecord, column.field), column.fieldDef))
.join(' ')
.toLowerCase();
......
import dayjs from 'dayjs';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
import { SOURCE_APPLICATION_NAME } from '@/config/application';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
const MATERIAL_PAGE_SIZE = 20;
function extractRecords(response) {
return [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
response?.result?.records,
].find(Array.isArray) || [];
}
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function inferMaterialTitle(fileName = '') {
return String(fileName || '').replace(/\.[^.]+$/, '').trim() || '未命名材料';
}
function normalizeFileAsset(uploadResult, file) {
const candidates = [
uploadResult?.file,
uploadResult?.data?.file,
uploadResult?.data,
uploadResult?.result?.file,
uploadResult?.result,
uploadResult,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'object') continue;
const url = candidate.url || candidate.path || candidate.location || candidate?.data?.url || '';
if (!url) continue;
return {
name: candidate.name || candidate.filename || file?.name || '未命名文件',
type: candidate.type || candidate.mimetype || file?.type || 'application/octet-stream',
size: Number(candidate.size || file?.size || 0),
url,
};
}
throw new Error('文件上传成功,但未获取到可用的文件地址。');
}
function normalizeMaterial(record = {}) {
const metadata = record?.metadata || {};
const fileAsset = metadata.file_asset || {};
return {
id: record._id || record.id || record.name || '',
materialTitle: metadata.material_title || record.title || fileAsset.name || '未命名材料',
fileAsset,
archiveCategory: metadata.archive_category || '',
archiveYear: metadata.archive_year || '',
};
}
export async function queryAuditMaterials(keyword = '') {
const escapedKeyword = escapeRegExp(String(keyword || '').trim());
const filter = escapedKeyword ? {
$or: [
{ 'metadata.material_title': { $regex: escapedKeyword, $options: 'i' } },
{ 'metadata.file_asset.name': { $regex: escapedKeyword, $options: 'i' } },
],
} : {};
const response = await queryRecords({
subject: AUDIT_MATERIAL_SUBJECT,
page: 0,
page_size: MATERIAL_PAGE_SIZE,
filter,
sort: '-updated_at -created_at',
mode: 'full',
});
return extractRecords(response)
.map(normalizeMaterial)
.filter((material) => material.id && material.fileAsset?.url);
}
export async function uploadAuditProjectFile(file) {
if (!file) throw new Error('请选择要上传的本地文件。');
return normalizeFileAsset(await uploadFile(file), file);
}
export async function createAuditProjectMaterial({ fileAsset, currentUser }) {
if (!fileAsset?.url) throw new Error('缺少有效的文件地址,无法写入审计材料中心。');
const uploaderId = currentUser?._id || currentUser?.id || '';
if (!uploaderId) throw new Error('未获取到当前登录用户,无法写入审计材料中心。');
const materialTitle = inferMaterialTitle(fileAsset.name);
const response = await createRecord({
subject: AUDIT_MATERIAL_SUBJECT,
title: materialTitle,
content_type: 'text',
metadata: {
material_title: materialTitle,
file_asset: {
name: fileAsset.name,
type: fileAsset.type || 'application/octet-stream',
size: Number(fileAsset.size || 0),
url: fileAsset.url,
},
source_app: SOURCE_APPLICATION_NAME,
uploader: uploaderId,
uploaded_at: dayjs().toISOString(),
},
});
const createdRecord = response?.data || response?.result || response;
return {
id: createdRecord?._id || createdRecord?.id || createdRecord?.name || true,
record: createdRecord,
};
}
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
import { APPLICATION_BASE_PATH, ROUTE_TITLES } from '@/config/application';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'audit-project-manager:notification-state';
const INTERNAL_ROUTE_PATHS = Object.keys(ROUTE_TITLES);
const INTERNAL_ROUTE_PATTERN = INTERNAL_ROUTE_PATHS
.map((path) => path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.sort((left, right) => right.length - left.length)
.join('|');
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);
return normalizedUserId
? toArray(notification?.data?.logs).some((log) => toId(log?.user_id) === normalizedUserId)
: false;
}
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 prefix = APPLICATION_BASE_PATH.replace(/\/$/, '');
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const content = getNotificationContent(notification);
const match = content.match(new RegExp(
`https?:\\/\\/[^\\s<>"']+|${escapedPrefix}\\/[^\\s<>"']+|(?:${INTERNAL_ROUTE_PATTERN})(?:[/?#][^\\s<>"']*)?`,
));
return trimTarget(match?.[0]);
}
export function resolveNotificationTarget(target) {
const normalizedTarget = trimTarget(target);
if (!normalizedTarget) return null;
const appPrefix = APPLICATION_BASE_PATH.replace(/\/$/, '');
if (/^https?:\/\//i.test(normalizedTarget)) {
try {
const url = new URL(normalizedTarget);
if (url.pathname.startsWith(`${appPrefix}/`)) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch (error) {
return null;
}
}
if (normalizedTarget.startsWith(`${appPrefix}/`)) {
return { href: normalizedTarget.slice(appPrefix.length), external: false };
}
return normalizedTarget.startsWith('/')
? { href: normalizedTarget, external: !INTERNAL_ROUTE_PATHS
.some((path) => normalizedTarget === path || normalizedTarget.startsWith(`${path}?`)) }
: null;
}
export function notifyNotificationStateChanged() {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
}
......@@ -12,6 +12,7 @@
| 往期项目归档 | `/audit_project_archive` | 只读查看审计项目历史快照 |
| 项目表单 | `/project_form` | 维护动态表单及字段 |
| 审计项目填写表 | `/audit_project_form` | 兼容外部填写和复核链接 |
| 通知消息 | `/notifications` | 查看消息和待办,并处理已读、动态动作及关联流程跳转 |
配置中的 `audit_indicator`、`risk_indicator`、`audit_process`、`audit_report`
没有菜单入口,属于复制配置残留,不注册为本应用路由。
......@@ -26,15 +27,17 @@
## 2. 项目列表与详情
1. 页面通过 `loadSubject`、`getSubjectView` 和 `POST /record/query` 读取字段、视图和记录。
2. 表格列、视图限制、排序、选择字段选项和字段分组来自服务端配置;字段分组读取当前 Grid View 的 `settings.groups`,按配置展示分组表头并支持展开或收起。审计项目管理列表不展示通用 `title` 列,项目名称及其他业务字段仍按 View 配置展示。
2. 表格列、视图限制、排序、选择字段选项和字段分组来自服务端配置;字段分组读取当前 Grid View 的 `settings.groups`,按配置展示分组表头并支持展开或收起。当前 Grid View 的 `settings.shortTitle === true` 时只隐藏通用 `title` 展示列;`false` 或未配置时正常展示。`title` 数据仍参与关键词搜索、排序、详情和保存,详情入口保留在项目名称和固定操作列。
View 中兼容历史字段 `metadata.project_finish_time` 时按数据定义显示为“项目完成时间”;
内部 `metadata.*` 路径不作为可见表头,字段取值路径和服务端配置保持不变。
3. 新建和编辑时业务字段统一写入 `metadata`;引用和用户字段只提交 ID,日期提交 ISO 8601。
4. 编辑模式下主键字段禁用;必填字段按 Subject 配置校验。
5. 新建与编辑弹窗的空控件按可见字段标签显示输入、选择或搜索提示;提示仅用于界面引导,不参与校验,也不写入提交数据。
6. 删除调用 `POST /record/batch/remove`,默认软删除;列表行内不提供编辑、复制或归档按钮,字段维护统一从详情侧栏进入。
7. 详情弹窗按可访问的 detail 视图显示字段,并支持字段内联维护;标题、阶段 Tabs 和顶部业务
操作固定在 Header,字段内容只在 Body 内纵向滚动,滚动内容不得穿过或遮挡阶段 Tabs。
7. 详情弹窗按符合条件的 detail 视图显示字段,并支持字段内联维护;当前 Detail View 的
`settings.shortTitle === true` 时隐藏记录标题和 `title` 字段,但始终保留“记录详情”功能标题和
Modal 可访问名称。标题、阶段 Tabs 和顶部业务操作固定在 Header,字段内容只在 Body 内纵向
滚动,滚动内容不得穿过或遮挡阶段 Tabs。
8. 列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。
9. 业务列表、动态表单数据表和关联记录表中的长文本最多展示两行,内容始终限制在所属列内;超出内容通过 HeroUI Tooltip 或项目详情查看,避免遮挡相邻列或由单条长文本过度拉高整行。弹窗内只读关联记录表按实际数据行高度收缩,不提供无业务价值的列宽拖拽。
......@@ -108,7 +111,8 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- `settings` 必须是合法 JSON;选择字段选项存放在 `settings.options`。
- 审计项目中的 `form` 字段在项目详情侧栏内默认完整展开:左侧按 `settings.group` 展示默认分组和自定义分组,右侧展示当前分组下的全部表单及数据表格。
- 表单字段支持在侧栏内添加/删除分组、添加/删除表单、切换表单模板和调整所属分组;删除自定义分组时,其中已配置的表单移入默认分组。
- 表单数据行使用通用表格展示,通过 Catalyst 弹窗新增或编辑;侧栏中的修改先作为草稿保留,点击“保存表单”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。
- 表单数据行使用 HeroUI Pro DataGrid 展示,通过 HeroUI Modal 新增或编辑;详情中的修改先作为
草稿保留,点击详情“保存”后统一写回 `{ form, data, settings }`,不向业务用户暴露 JSON。
- 关联的表单模板不存在或加载失败时,在当前表单内容内显示“表单模板不可用”,不展示内部
模板标识,也不继续展示缺少定义的表格;编辑态可以重新选择有效模板,现有数据不会自动改写。
- 模板存在但缺少字段定义时显示“暂无可展示字段”,不使用已有数据对象的内部 key 临时生成
......@@ -122,6 +126,32 @@ Word 导出分别读取 `audit_plain_template`、`audit_verification_template`
- 登录页首次打开时调用 `GET /auth/captcha` 获取验证码图片和 token;登录请求在既有账号、
加密密码与 `type=password` 基础上提交 `captcha` 和 `token`。用户可点击验证码刷新;登录
失败时清空验证码输入并重新获取验证码,成功后的 token 保存和 redirect 流程保持不变。
- Subject 视图继续执行服务端配置的访问限制、条件过滤和字段禁用规则。
- Grid View 的 `settings.viewAuth` 控制视图可见性,当前激活 Grid View 的 `settings.auth` 控制
详情字段编辑权限,`settings.disableColumns` 对所有用户始终只读。授权只读取
`user.applications[]` 中 `name = "audit_project_manager"` 的应用项;系统管理员、系统账号及该
应用“管理”角色可旁路 View/字段 auth,但不能旁路 `disableColumns`。普通用户缺少配置时默认
拒绝;没有可访问 Grid View 时不查询记录、不创建兜底视图,也不展示新增、批量和行操作;只有
一个可访问 View 时隐藏 Tabs 轨道。
- 历史快照列表只读,不允许新建、复制或删除。
- 批量归档未返回任何历史记录 ID 时按失败处理,并保留原审计项目数据。
## 10. 消息通知
- Navbar 的通知按钮位于主题按钮左侧,未读数来自 `GET /notice/customer/count`;0 不显示,
1–99 显示真实数量,100 及以上显示 `99+`,并通过通知状态事件和 60 秒轮询刷新。
- `/notifications` 使用原平台通知接口,支持消息/待办、全部/未读、300ms 搜索、固定每页 10 条、
单条已读、全部已读和服务端配置的动态动作;未读筛选向服务端提交 `status = "unread"`。
- 通知正文按纯文本显示,不执行 HTML。以 `/audit_project_manager_new` 开头的站内链接剥离自身
base 后交给 Umi;本应用已注册的裸业务路由按站内处理,其它应用和外部 HTTP(S) 链接在无
`window.opener` 的新窗口打开。
## 11. 文件与审计材料中心
- 审计项目详情中所有运行时可编辑的 `file` 字段提供“材料中心 / 本地上传”,单值或多值取自
Subject 字段的 `multiple` 配置。材料中心查询 `am_material` 最近 20 条,并按材料标题和文件名
远程搜索;选择后只把 `{name,type,size,url}` 快照写入业务草稿。
- 本地文件先调用 `/upload` 并进入当前详情草稿。取消编辑不创建材料主档;单值替换,多值追加并
按 URL 去重,移除或清空业务附件不删除材料中心主档或底层文件。
- 用户点击详情“保存”后,系统才用 `source_app = "audit_project_manager"`、当前用户、字段快照和
ISO 时间逐个创建 `am_material`。全部建档成功后才调用既有业务记录更新回调;部分失败会在当前
编辑会话保留已建档标记,重试只创建尚未成功的材料,避免重复主档。
import dayjs from 'dayjs';
import { AUTHORIZATION_APPLICATION_NAME } from '@/config/application';
export function normalizeText(value) {
return String(value ?? '').trim();
......@@ -62,60 +63,88 @@ export function getCurrentUserFromStorage() {
}
}
function collectUserGroups(user) {
return toArray(user?.groups)
.flatMap((group) => [group?._id, group?.name, group?.display_name, group])
function collectIdentityValues(values) {
return toArray(values)
.flatMap((value) => value && typeof value === 'object'
? [value._id, value.name, value.display_name, value.displayName, value.username]
: value)
.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));
}
export function hasAuthAccess(authRule, user = getCurrentUserFromStorage()) {
if (!authRule) {
function getAuthorizationRule(authRule) {
return ['group', 'role', 'permission', 'username'].includes(authRule?.type)
? 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;
}
if (!user) {
return false;
}
const { roles } = collectApplicationAuthorization(user, applicationName);
return intersects(roles, ['管理']);
}
if (user.type === 'admin') {
return true;
export function hasAuthAccess(
authRule,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
if (!user) return false;
if (hasApplicationManagementAccess(user, applicationName)) return true;
const { groups, roles, permissions } = collectApplicationAuthorization(user, applicationName);
if (typeof authRule === 'string') {
return intersects(roles, [authRule]);
}
if (authRule.type === 'group') {
return intersects(collectUserGroups(user), authRule.groups);
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 true;
return false;
}
export function getFieldValue(record, fieldPath) {
......@@ -249,12 +278,18 @@ 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 fieldName = getFieldNameFromPath(column.field);
const configuredFieldDef = getFieldDefinition(column.field, subject);
......@@ -274,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) => {
const fieldPath = `metadata.${field.name}`;
......@@ -298,6 +335,21 @@ 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) => {
......@@ -356,18 +408,31 @@ export function checkViewCondition(condition, record) {
: results.every(Boolean);
}
export function isViewAccessible(view, user = getCurrentUserFromStorage()) {
return hasAuthAccess(view?.settings?.viewAuth, user);
export function isViewAccessible(
view,
user = getCurrentUserFromStorage(),
applicationName = AUTHORIZATION_APPLICATION_NAME,
) {
return hasAuthAccess(getAuthorizationRule(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);
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(getAuthorizationRule(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