Commit 84dfb853 authored by Andy-bubu's avatar Andy-bubu

feat: align Xinyuan cross-app capabilities

parent 0f9e79a0
......@@ -14,6 +14,7 @@ const ROUTE_PATHS = {
selfCheckDraftTemplate: '/self_check_the_draft_template',
selfCheckWorkingDraft: '/self_check_the_working_draft',
annualSubmissionMaterials: '/self_inspection_annual_submission_materials',
notifications: '/notifications',
} as const;
const APP_BASE_PATH = '/integrity_practice_management/';
......@@ -75,6 +76,10 @@ export default defineConfig({
path: ROUTE_PATHS.annualSubmissionMaterials,
component: './integrity_practice_management/SubjectWorkbench',
},
{
path: ROUTE_PATHS.notifications,
component: './integrity_practice_management/notifications/NotificationListPage',
},
],
},
],
......
......@@ -9,21 +9,25 @@ app.js (主题初始化)
-> AppShell (Pro Sidebar.Provider)
-> DashboardSidebar (Pro Sidebar + HeroUI Dropdown)
-> DashboardNavbar (Pro Navbar + Sidebar.Trigger)
-> Badge.Anchor + Badge 通知入口 -> /notifications
-> SubjectWorkbench (七个 Subject 配置入口)
-> RecordWorkbench (Tabs/Search/DataGrid/Pagination/ActionBar)
-> RecordDetailDrawer (Modal/Tabs/Surface/动态字段)
-> 整窗草稿 -> 待建档本地附件 -> am_material -> 业务 Record
-> UserAssignmentDialog / AlertDialog
```
## 责任边界
- `src/config/application.js`:应用名、base、主题存储键。
- `src/config/application.js`:稳定 `APPLICATION_NAME`、展示名、base、主题存储键;路由、应用切换与授权判断从同一应用标识派生。
- `src/design-system/`:权威主题、Tokens 和跨应用 Shell 样式;页面不得复制这些规则。
- `AppShell/DashboardSidebar/DashboardNavbar/ApplicationSwitcherDialog`:业务无关的共享壳层。
- `ApplicationSwitcherDialog` 从既有 `SiteContext.site.group` 读取动态分组,从 `GET /application/info` 与 `UserContext.applications` 合并真实可访问应用;分组仅影响呈现,不改变路由、会话或跳转。
- `SubjectWorkbench`:把现有 Subject/View/动作配置适配为工作台 props,不定义组件外观。
- `record-workbench`:DataGrid/Pagination/ActionBar 组合;详情能力统一映射为操作列首个预览图标,首个业务列仅展示文本;不发请求、不解释业务动作。
- `RecordDetailDrawer`:详情草稿、Modal 状态和字段编排;保存仍由 `useSubjectRecord` 处理。
- `RecordDetailDrawer`:详情整窗草稿、Modal 状态和字段编排;一次保存通过 `useSubjectRecord.handleFieldsUpdate` 提交。
- `integrityPracticeMaterialService`:`am_material` 查询、`/upload` 结果快照规范化和材料建档;应用标识由廉洁从业业务层固定为 `integrity_practice_management`。
- `notifications/notificationService`:平台通知查询、已读、动态 action、未读事件和 base-aware 链接解析。
- `TailwindEditableField`:按动态字段类型选择官方控件;不硬编码选项或 API 字段。
- service/hook:唯一业务请求与数据转换边界,UI 组件不改变协议。
......@@ -33,8 +37,12 @@ app.js (主题初始化)
路由 -> Subject 配置 -> GET Subject/View -> POST record/query
-> DataGrid 展示/选择 -> 配置动作 -> 现有 service 回调
-> 详情 Modal -> 本地草稿 -> 统一保存 -> PUT record/:name
-> 本地附件草稿 -> POST am_material(逐文件、可重试)-> PUT record/:name
Navbar -> GET notice/customer/count -> /notifications -> query/read/action
```
Grid View 的 `viewAuth` 在查询前过滤;没有可访问 View 时数据链在 `POST record/query` 之前终止。打开详情时,`useSubjectRecord` 在 View 摘要之后继续读取每个 View 的完整配置。当前 Detail 有有效 `auth` 或 `disableColumns` 时,其字段权限整套优先;两项都未配置或没有实际 Detail 时,整体回退打开它的 Grid `authorizationView`。Detail 与 Grid 不合并、也不逐字段回退;`disableColumns` 始终强制只读。切换 Detail 后,字段状态和编辑入口只按当前 Detail 的展示字段立即重算。Grid `viewAuth` 不用于隐藏详情字段。
主题状态写入 `xinyuan_color_theme`,同时更新根 `data-theme` 与 `dark` class,使 Portal 与页面一致。应用切换数据来自真实 application info;登出调用现有 logout API 后清理登录态并回到 base-aware 登录路径。
## 历史源码隔离
......
......@@ -22,6 +22,15 @@
- [x] 详情/编辑 Modal、指派 Modal、删除 AlertDialog
- [x] Select、Autocomplete、DatePicker、DropZone 直接 compound API
- [x] 活动路由 KPI/图表判定为不适用
- [x] Grid `shortTitle` 严格布尔隐藏标题展示列,标题数据与查询协议保留
- [x] Detail `shortTitle` 随活动详情 View 隐藏记录标题与 title 字段
- [x] View/字段授权只读取 `integrity_practice_management` 应用项;零 View 不查询、不展示操作,单 View 隐藏 Tabs
- [x] 稳定 `APPLICATION_NAME` 统一路由、应用切换与授权归属;不合并用户顶层或其他应用角色/用户组
- [x] Detail 加载完整 View 配置;有效 Detail 字段权限整套优先,无配置时整体回退当前 Grid,不合并或逐字段回退
- [x] `disableColumns` 对管理员仍强制只读;编辑入口只检查当前激活 Detail 展示字段
- [x] `/notifications` 消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 action 与 base-aware 跳转
- [x] Navbar 36px Bell、真实未读数和 0/99/99+ 显示规则
- [x] 所有可编辑 file 字段提供材料中心/本地上传;本地草稿仅在详情保存时建档
## 保护与扫描
......@@ -55,3 +64,17 @@
- [ ] 全仓旧组件扫描为零:未完成。未挂载历史 `src/pages/problem_rectification` 与 `src/catalyst-ui-kit` 仍保留,且不进入当前应用路由。
- [ ] KPI/图表 Tooltip 页面验收:不适用。七条活动路由没有这些业务模块,未伪造数据或页面。
- [ ] 五项功能全部真实线上失败/重试矩阵:未完成。当前列表、详情和通知接口可用,但没有制造材料部分成功、业务保存失败或通知 1/99/100 的业务数据;不得以浏览器夹具替代真实线上状态验收。
## 2026-08-26 五项功能追加验收
- [x] 真实 `shortTitle: true` Grid View 隐藏标题展示列,保留上下文首列和操作列预览入口
- [x] Navbar 未读 0 隐藏 Badge,Bell 进入本应用 `/notifications`
- [x] 通知页真实调用 `/notice/customer/query` 与 `/notice/customer/count`,空态、消息/待办和全部/未读控件可见
- [x] 目标页面重新登录后的控制台 0 error;保留两条既有 React Aria 开发 warning
- [x] 真实详情权限来源:Andy 打开未归档记录时记录、Subject、View 摘要及四个完整 View 请求均为 200;无字段权限配置的 Detail 整体回退 Grid,并正常显示编辑入口
- [x] 真实 `disableColumns`:已归档 Detail 的完整配置优先,Andy 虽为系统管理员,进入编辑后配置禁用的模板、部门、项目结果、填报人、审批人等仍为只读,仅未禁用字段可编辑
- [ ] 真实材料事务:尚未完成文件单值/多值、取消、失败和无重复重试验收;未制造业务数据
- [ ] Detail 完整角色矩阵:Andy 是系统管理员且 `applications` 为空,未提供当前应用“管理”角色和普通用户登录凭据;无权限视图、普通用户默认只读及角色旁路仍未做真实账号验收
- [ ] 真实角色矩阵和通知 1/99/100/action:当前 Andy 运行数据只有 admin 身份且通知总数为 0,没有制造或写入测试业务数据
- [ ] 问题整改同视口稳定会话:基准服务在线,但独立登录验证码会话未稳定建立;本轮没有把仅代码 diff 计作浏览器对比通过
......@@ -24,6 +24,11 @@ Inter 禁止通过 `src/tailwind.css` 导入。Tailwind v4 内容扫描必须排
- 行动作:1-3 项全部直出;4 项以上直出前两项和 More Dropdown;危险项排序最后。
- Modal:详情/表单使用 HeroUI Modal;确认使用 AlertDialog;内容型详情使用 4xl/1088px、24px 圆角,查看态只允许一个 16px 圆角的 secondary Surface。嵌套 DataGrid 使用 20px 圆角,表头与固定表头使用 tertiary Surface,表体与固定单元格回到 default Surface,禁止表头与外层详情 Surface 使用同一层颜色。
- 动态字段:Input、TextArea、Checkbox、Select、Autocomplete、DatePicker;附件使用 Pro DropZone。
- View 标题:仅 `settings.shortTitle === true` 隐藏 Grid 的 title 展示列;Detail View 同配置隐藏记录副标题与 title 字段,Modal 功能标题始终存在。
- View 权限:以稳定 `APPLICATION_NAME = integrity_practice_management` 定位授权,只读取 `user.applications[]` 中当前应用项目的 groups/roles/permissions;不得合并顶层身份或其他应用授权。Grid `viewAuth` 只控制 Tab 可见性;零可访问 Grid View 时禁止查询与操作,一个 View 时隐藏 Tabs。
- Detail 字段权限:打开详情时加载 `GET /subject/:subjectName/view/:viewName` 的完整配置。当前 Detail 有有效 `auth` 或 `disableColumns` 时整套使用 Detail;两项均未配置或不存在实际 Detail 时整体回退打开它的 Grid。两套配置不合并、不逐字段回退;`disableColumns` 对所有用户始终只读,编辑入口只根据当前激活 Detail 展示字段计算。
- 通知:Navbar 直接组合 `Badge.Anchor + Badge + Button + Tooltip`,Bell 位于主题按钮左侧;通知页使用平台真实接口和 HeroUI Tabs/SearchField/Pagination/Card。
- 文件:可编辑 file 字段统一使用 HeroUI Tabs、Autocomplete 和 Pro DropZone;材料中心选择及本地上传都只向业务草稿写 `{name,type,size,url}`。
- 远程引用:使用 HeroUI `Popover.Root + Button + SearchField + controlled ListBox`。多选触发器固定 40px 高并只显示“已选择 N 项”,候选长标题单行省略且通过 `title` 暴露全文;弹层最大 24rem、移动端收敛到 `100vw - 24px`、列表最大 288px。多选后保持打开,单选后关闭,清空不改变原 ID 保存协议。
- KPI/图表:仅在真实业务路由存在时使用 Pro KPI、PieChart、BarChart、ComposedChart、ChartTooltip;禁止 Card 数字卡或 ECharts 私有主题。
......
......@@ -132,3 +132,16 @@ pnpm install --lockfile-only --offline --frozen-lockfile
- 根运行时为 React/React DOM `19.2.8`、HeroUI/Styles `3.2.4`、React Aria Components `1.20.0`、React Aria Utils `3.34.1`,满足 HeroUI Pro `1.0.0-beta.8` peer 范围;冻结锁文件检查显示 `Already up to date`。
- 依赖树还包含 Umi/DVA 工具链内部 React/React DOM 18 副本;`.umirc.ts` 已把应用运行时 alias 到根 React 19。没有缺失 HeroUI peer。
- Node ESM 脚本已逐项导入根入口、Sidebar、Navbar、DataGrid、DropZone、ActionBar、EmptyState、KPI、PieChart、BarChart、ComposedChart、ChartTooltip,并解析 CSS 子路径,全部通过。
## 2026-08-26 五项功能审计
- 应用范围:统一展示名为“廉洁从业管理”,属于六个完整功能应用,实施 Grid/Detail `shortTitle`、Subject View 权限、通知中心和审计材料中心文件流程。
- 授权来源:`APPLICATION_NAME = integrity_practice_management` 是路由、应用切换和授权判断的唯一稳定标识。只读取 `user.applications[]` 中该应用项目,不合并顶层身份、其他应用角色或其他应用用户组;系统管理员/系统账号及该应用“管理”角色旁路 View/字段授权,`disableColumns` 始终只读。
- Detail 权限来源:详情先获取 View 摘要,再逐项调用 `GET /subject/:subjectName/view/:viewName` 补齐完整配置。当前 Detail 有有效 `auth`/`disableColumns` 时整套优先;两项都未配置或没有实际 Detail 时整体回退当前 Grid,二者不合并也不逐字段回退。Grid `viewAuth` 仅控制 Tab 可见性,编辑入口只检查当前激活 Detail 展示字段。
- 真实 View:`self_inspection_submission_form`、`statistical_table`、`integrity_practice_report`、`self_check_the_working_draft` 已确认存在严格布尔 `shortTitle: true`;未配置的 View 保留标题列。
- 文件协议:运行时确认 `integrity_practice_report.metadata.archive_attachment` 为 `file` 且 `multiple: true`;业务值继续使用 `{name,type,size,url}` 快照。
- 通知协议:沿用既有 `/notice/customer/query`、`/notice/customer/count`、`/notice/customer/read` 与动态 action API,新增活动路由 `/notifications`,base 仍为 `/integrity_practice_management/`。
- 材料协议:查询 `am_material` 最近 20 条;本地文件仍先走 `/upload`,详情保存时才以 `source_app = integrity_practice_management` 建档,材料全部成功后才更新业务记录。
- 原始缺口:旧实现会在零授权 View 时创建“全部”兜底并查询;详情按单字段累计 payload;Navbar 无通知入口;文件字段只有本地上传。上述缺口已按问题整改基准收敛。
- 浏览器实测:目标运行于 `http://localhost:8006/integrity_practice_management/`,问题整改基准运行于 `http://localhost:8004/problem_rectification_new/`。Andy 登录后,`self_inspection_submission_form` 的真实 `shortTitle: true` View 已确认不渲染标题列,首列不承担预览,操作列保留预览/编辑/更多;Navbar 未读数真实返回 0,角标不显示;点击 Bell 正确进入 `/notifications`,并实际发出 `/notice/customer/count` 与 `/notice/customer/query` 请求。打开未归档真实记录时,记录、Subject、View 摘要和四个 `GET /subject/:subjectName/view/:viewName` 请求全部为 200;该 Detail 无字段权限配置,整体回退当前 Grid 后正常显示编辑入口。切换已归档 Grid 后,实际 Detail 带 `disableColumns`,Andy 虽为系统管理员,编辑态中模板、部门、项目结果、填报人、审批人等仍为只读,仅未禁用字段可编辑。控制台 0 error(保留两条既有 React Aria 开发 warning)。
- 浏览器限制:Andy 是系统管理员且当前返回的 `applications` 为空,能够验证系统管理员旁路与 Detail `disableColumns` 不可旁路,但不能替代目标应用“管理”角色、普通用户和无权限用户矩阵。材料选择/上传/取消/部分失败/重试、通知 1/99/100 和动态 action 也没有制造业务数据验收;问题整改基准的独立登录会话未稳定建立。未使用伪造业务数据绕过这些限制。
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
......@@ -25,11 +26,16 @@ import UploadOutlined from '@ant-design/icons/UploadOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
function iconStyle(size, style) {
return size === undefined ? style : { fontSize: size, ...style };
}
export const FIELD_SUFFIX_ICON_STYLE = Object.freeze({
color: 'var(--field-placeholder, var(--muted))',
});
function withSize(Icon) {
return function AppIcon({ className, size, style, ...props }) {
return (
......@@ -44,6 +50,7 @@ function withSize(Icon) {
}
export const AppGrid = withSize(AppstoreOutlined);
export const Bell = withSize(BellOutlined);
export const Archive = withSize(ArchiveOutlined);
export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined);
......@@ -54,6 +61,7 @@ export const Delete = withSize(DeleteOutlined);
export const Download = withSize(DownloadOutlined);
export const Edit = withSize(EditOutlined);
export const Eye = withSize(EyeOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const FileDone = withSize(FileDoneOutlined);
export const FileText = withSize(FileTextOutlined);
export const Fullscreen = withSize(FullscreenOutlined);
......@@ -62,8 +70,10 @@ export const LogOut = withSize(LogoutOutlined);
export const Moon = withSize(MoonOutlined);
export const More = withSize(MoreOutlined);
export const Reload = withSize(ReloadOutlined);
export const ArrowPathIcon = Reload;
export const Send = withSize(SendOutlined);
export const Search = withSize(SearchOutlined);
export const MagnifyingGlassIcon = Search;
export const Settings = withSize(SettingOutlined);
export const Sun = withSize(SunOutlined);
export const Tool = withSize(ToolOutlined);
......
......@@ -15,7 +15,7 @@ function getInitialTheme() {
}
}
export default function AppShell({ children, open, onOpenChange, title, ...sidebarProps }) {
export default function AppShell({ children, notificationCount, notificationPath, open, onOpenChange, title, ...sidebarProps }) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
......@@ -39,7 +39,13 @@ export default function AppShell({ children, open, onOpenChange, title, ...sideb
>
<DashboardSidebar theme={theme} {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar theme={theme} title={title} onThemeChange={setTheme} />
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={notificationPath ? () => history.push(notificationPath) : undefined}
theme={theme}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
......
import xinyuanLogo from '@/assets/xy-logo-red.png';
import { ExclamationTriangleIcon } from '@/components/AppIcons';
import { APPLICATION_DISPLAY_NAME, APPLICATION_ROUTE_NAME } from '@/config/application';
import { APPLICATION_DISPLAY_NAME, APPLICATION_NAME } from '@/config/application';
import { getApplications } from '@/services/DataService';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Card, Chip, Description, Modal, Skeleton, Surface } from '@heroui/react';
......@@ -15,7 +15,7 @@ function unwrapApplications(response) {
function normalizeApplication(application = {}) {
const name = application.name || '';
const current = name === APPLICATION_ROUTE_NAME;
const current = name === APPLICATION_NAME;
return {
name,
title: current ? APPLICATION_DISPLAY_NAME : application.title || name || '未命名应用',
......@@ -28,7 +28,7 @@ function normalizeApplication(application = {}) {
function mergeApplications(fetched, fallbacks) {
const merged = new Map();
[...fallbacks, ...fetched, { name: APPLICATION_ROUTE_NAME, title: APPLICATION_DISPLAY_NAME }]
[...fallbacks, ...fetched, { name: APPLICATION_NAME, title: APPLICATION_DISPLAY_NAME }]
.map(normalizeApplication)
.filter((application) => application.name)
.forEach((application) => merged.set(application.name, { ...merged.get(application.name), ...application }));
......@@ -77,7 +77,7 @@ function buildApplicationSections(applications, applicationGroups) {
}
function isCurrentApplication(application) {
if (application.name === APPLICATION_ROUTE_NAME) return true;
if (application.name === APPLICATION_NAME) return true;
if (typeof window === 'undefined') return false;
try {
const target = new URL(application.url, window.location.origin);
......@@ -142,7 +142,7 @@ export default function ApplicationSwitcherDialog({ open, onClose, siteInfo, use
const [status, setStatus] = useState('idle');
const [errorMessage, setErrorMessage] = useState('');
const currentApplicationName = useMemo(
() => applications.find(isCurrentApplication)?.name || APPLICATION_ROUTE_NAME,
() => applications.find(isCurrentApplication)?.name || APPLICATION_NAME,
[applications],
);
const applicationSections = useMemo(
......
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 +21,26 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label={`通知消息,${normalizedNotificationCount}条未读`}
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>
<Tooltip delay={0}>
<Button
isIconOnly
......
import { Upload } from '@/components/AppIcons';
import { DropZone } from '@heroui-pro/react/drop-zone';
import { useCallback } from 'react';
export default function FileUploadField({
accept,
ariaLabel = '选择文件',
className = '',
description = '将文件拖到此处,或从本地选择。',
isDisabled = false,
fileName = '',
multiple = false,
onFilesChange,
triggerLabel = '选择文件',
}) {
const emitFiles = useCallback(
(files) => {
if (!isDisabled) {
onFilesChange?.(Array.from(files || []));
}
},
[isDisabled, onFilesChange],
);
const handleDrop = useCallback(
async (event) => {
const fileItems = Array.from(event?.items || []).filter(
(item) => item?.kind === 'file' && typeof item.getFile === 'function',
);
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(multiple ? files : files.slice(0, 1));
},
[emitFiles, multiple],
);
return (
<DropZone className={className}>
<DropZone.Area
aria-label={ariaLabel}
isDisabled={isDisabled}
onDrop={handleDrop}
>
<DropZone.Input
accept={accept}
disabled={isDisabled}
multiple={multiple}
onSelect={emitFiles}
/>
<DropZone.Icon>
<Upload aria-hidden="true" />
</DropZone.Icon>
<DropZone.Label>{fileName || '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>
{fileName
? `已选择${multiple ? '文件,可继续添加。' : '文件,可重新选择进行替换。'}`
: description}
</DropZone.Description>
<DropZone.Trigger isDisabled={isDisabled}>
<Upload aria-hidden="true" />
{triggerLabel}
</DropZone.Trigger>
</DropZone.Area>
</DropZone>
);
}
import {
Calendar as HeroCalendar,
CloseButton,
DateField,
DatePicker,
I18nProvider,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useMemo } from 'react';
import {
Calendar as CalendarIcon,
Close,
FIELD_SUFFIX_ICON_STYLE,
} from './AppIcons';
function parseValue(value, mode) {
if (!value) {
return null;
}
try {
if (mode === 'month') {
return parseDate(`${String(value).slice(0, 7)}-01`);
}
return mode === 'datetime'
? parseDateTime(String(value).slice(0, 16))
: parseDate(String(value).slice(0, 10));
} catch {
return null;
}
}
function formatValue(value, mode) {
if (!value) {
return '';
}
const rawValue = value.toString();
if (mode === 'month') {
return rawValue.slice(0, 7);
}
if (mode === 'date') {
return rawValue.slice(0, 10);
}
if (rawValue.includes('T')) {
return rawValue.slice(0, 16);
}
return `${rawValue.slice(0, 10)}T00:00`;
}
function parseTimeValue(value) {
if (!value || !String(value).includes('T')) {
return null;
}
try {
return parseTime(String(value).slice(11, 16));
} catch {
return null;
}
}
function formatTimeValue(value) {
return value ? value.toString().slice(0, 5) : '00:00';
}
function combineDateAndTime(value, dateValue, timeValue) {
const dateText = dateValue
? dateValue.toString().slice(0, 10)
: String(value || '').slice(0, 10);
if (!dateText) {
return '';
}
return `${dateText}T${formatTimeValue(timeValue)}`;
}
function emitDateChange(onChange, name, nextValue) {
onChange?.({
target: {
name,
value: nextValue,
},
});
}
export default function FormDatePicker({
'aria-label': ariaLabel,
className,
disabled = false,
granularity,
isClearable = true,
max,
min,
mode,
name,
onChange,
readOnly = false,
required = false,
type = 'date',
value,
variant,
...props
}) {
const resolvedMode =
mode === 'month' || type === 'month'
? 'month'
: mode === 'datetime' || type === 'datetime-local'
? 'datetime'
: 'date';
const resolvedGranularity =
granularity ||
(resolvedMode === 'datetime'
? 'minute'
: resolvedMode === 'month'
? 'month'
: 'day');
const parsedValue = useMemo(
() => parseValue(value, resolvedMode),
[resolvedMode, value],
);
const minValue = useMemo(() => parseValue(min, resolvedMode), [min, resolvedMode]);
const maxValue = useMemo(() => parseValue(max, resolvedMode), [max, resolvedMode]);
const timeValue = useMemo(() => parseTimeValue(value), [value]);
const canClear =
isClearable && Boolean(value) && !disabled && !readOnly && !required;
return (
<I18nProvider locale="zh-CN">
<DatePicker
{...props}
aria-label={ariaLabel || '选择日期'}
className={['w-full', className].filter(Boolean).join(' ')}
granularity={resolvedGranularity}
isDisabled={disabled}
isReadOnly={readOnly}
isRequired={required}
maxValue={maxValue || undefined}
minValue={minValue || undefined}
value={parsedValue}
onChange={(nextValue) =>
emitDateChange(onChange, name, formatValue(nextValue, resolvedMode))
}
>
<DateField.Group className="relative" fullWidth variant={variant}>
<DateField.Input className={canClear ? 'pe-14' : 'pe-8'}>
{(segment) => (
<DateField.Segment segment={segment} />
)}
</DateField.Input>
<DateField.Suffix className="absolute inset-y-0 end-1 mr-0 gap-0">
{canClear ? (
<CloseButton
aria-label={`清空${ariaLabel || '日期'}`}
onPress={() => emitDateChange(onChange, name, '')}
>
<Close
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</CloseButton>
) : null}
<DatePicker.Trigger
aria-label="打开日期选择"
className="size-6 w-6 shrink-0 justify-center p-1 text-field-placeholder"
>
<DatePicker.TriggerIndicator>
<CalendarIcon
className="grid size-4 place-items-center leading-none [&>svg]:block [&>svg]:size-4"
size={16}
style={FIELD_SUFFIX_ICON_STYLE}
/>
</DatePicker.TriggerIndicator>
</DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover placement="bottom start">
<HeroCalendar>
<HeroCalendar.Header>
<HeroCalendar.NavButton slot="previous" />
<HeroCalendar.Heading />
<HeroCalendar.NavButton slot="next" />
</HeroCalendar.Header>
<HeroCalendar.Grid>
<HeroCalendar.GridHeader>
{(day) => <HeroCalendar.HeaderCell>{day}</HeroCalendar.HeaderCell>}
</HeroCalendar.GridHeader>
<HeroCalendar.GridBody>
{(date) => <HeroCalendar.Cell date={date} />}
</HeroCalendar.GridBody>
</HeroCalendar.Grid>
</HeroCalendar>
{resolvedMode === 'datetime' ? (
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-sm text-muted">时间</span>
<TimeField
aria-label="选择时间"
className="w-36"
granularity="minute"
hideTimeZone
hourCycle={24}
value={timeValue}
onChange={(nextTime) =>
emitDateChange(
onChange,
name,
combineDateAndTime(value, parsedValue, nextTime),
)
}
>
<TimeField.Group fullWidth variant={variant}>
<TimeField.Input>
{(segment) => (
<TimeField.Segment segment={segment} />
)}
</TimeField.Input>
</TimeField.Group>
</TimeField>
</div>
) : null}
</DatePicker.Popover>
</DatePicker>
</I18nProvider>
);
}
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { queryRecords } from '@/services/DataService';
import { Button, Label } from '@heroui/react';
import { useEffect, useMemo, useRef, useState } from 'react';
const DEFAULT_ID_LOOKUP_CHUNK_SIZE = 50;
const DEFAULT_SEARCH_FIELDS = [];
const PERSONNEL_SUBJECTS = new Set(['project_member', 'user']);
const FULL_MODE_SUBJECTS = new Set([
'eg_project_info',
'project_member',
]);
function normalizeText(value) {
return String(value || '').trim();
}
function escapeMongoRegex(value) {
return normalizeText(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildSearchFilter(baseFilter, query, searchFields = []) {
const trimmedQuery = normalizeText(query);
const normalizedSearchFields = Array.from(
new Set((searchFields || []).map((field) => normalizeText(field))),
).filter(Boolean);
if (!trimmedQuery || normalizedSearchFields.length === 0) {
return baseFilter;
}
const keywordPattern = escapeMongoRegex(trimmedQuery);
const searchFilter = {
$or: normalizedSearchFields.map((field) => ({
[field]: {
$regex: keywordPattern,
$options: 'i',
},
})),
};
if (!baseFilter || Object.keys(baseFilter).length === 0) {
return searchFilter;
}
return {
$and: [baseFilter, searchFilter],
};
}
function pickSecondaryText(candidates, { label = '', excluded = [] } = {}) {
const normalizedLabel = normalizeText(label);
const excludedSet = new Set(
[normalizedLabel, ...excluded.map((item) => normalizeText(item))].filter(
Boolean,
),
);
for (const candidate of candidates) {
const value = normalizeText(candidate);
if (!value || excludedSet.has(value)) {
continue;
}
return value;
}
return '';
}
function resolveRecordId(record) {
if (!record) {
return undefined;
}
// Reference 写入协议:只接受 _id / id
return record.id || record._id;
}
function resolveReferenceValueId(value) {
if (!value) {
return '';
}
if (typeof value === 'object') {
return normalizeText(resolveRecordId(value));
}
return normalizeText(value);
}
function resolveRecordSubjectName(record, fallbackSubject = '') {
if (!record) {
return normalizeText(fallbackSubject);
}
if (typeof record.subject === 'string') {
return normalizeText(record.subject);
}
return normalizeText(record.subject?.name || fallbackSubject);
}
function resolvePersonnelPresentation(record) {
const metadata = record?.metadata || {};
const id = resolveRecordId(record) || '-';
const label =
normalizeText(
metadata.user_name ||
record?.user_name ||
record?.label ||
record?.display_name ||
metadata.display_name ||
record?.title,
) ||
normalizeText(
metadata.login_name ||
record?.login_name ||
record?.username ||
metadata.username ||
record?.name,
) ||
id;
const description = pickSecondaryText(
[
metadata.login_name,
record?.login_name,
record?.username,
metadata.username,
],
{ label, excluded: [id] },
);
return {
label,
description,
};
}
function resolveRecordPresentation(record, fallbackSubject = '') {
if (!record) {
return {
label: '-',
description: '',
};
}
const metadata = record.metadata || {};
const id = resolveRecordId(record) || '-';
const subjectName = resolveRecordSubjectName(record, fallbackSubject);
if (PERSONNEL_SUBJECTS.has(subjectName)) {
return resolvePersonnelPresentation(record);
}
const projectName = normalizeText(
metadata.project_name || record.project_name,
);
const projectCode = normalizeText(
metadata.project_code || record.project_code,
);
if (projectName && projectCode) {
return {
label: `${projectName} (${projectCode})`,
description: '',
};
}
const label =
normalizeText(
record.title ||
record.label ||
metadata.project_name ||
metadata.project_code ||
metadata.customer_full_name ||
metadata.user_name ||
metadata.login_name ||
metadata.code ||
record.name,
) || id;
const description = pickSecondaryText(
[
metadata.project_code,
record.project_code,
metadata.customer_full_name,
metadata.user_name,
metadata.login_name,
metadata.code,
record.name,
],
{ label, excluded: [id] },
);
return {
label,
description,
};
}
function createOptionFromRecord(record, fallbackSubject = '') {
const id = resolveRecordId(record);
if (!id) {
return null;
}
const presentation = resolveRecordPresentation(record, fallbackSubject);
return {
id,
label: presentation.label,
description: presentation.description,
record,
};
}
function createOptionFromReferenceValue(value, fallbackSubject = '') {
if (!value || typeof value !== 'object') {
return null;
}
const option = createOptionFromRecord(value, fallbackSubject);
if (
option &&
(option.description ||
(option.label && option.id && normalizeText(option.label) !== option.id))
) {
return option;
}
const id = resolveReferenceValueId(value);
if (!id) {
return null;
}
const label =
normalizeText(
value.label || value.title || value.name || value.display_name,
) || '';
if (!label || label === id) {
return null;
}
return {
id,
label,
description: '',
record: value,
};
}
function uniqIds(ids) {
const result = [];
const seen = new Set();
for (const id of ids) {
const normalizedId = resolveReferenceValueId(id);
if (!normalizedId || seen.has(normalizedId)) {
continue;
}
seen.add(normalizedId);
result.push(normalizedId);
}
return result;
}
function resolveQueryMode(subject) {
return FULL_MODE_SUBJECTS.has(normalizeText(subject)) ? 'full' : 'basic';
}
export default function ReferenceSearchSelect({
subject,
value,
onChange,
multiple = false,
multipleDisplayMode = 'tags',
disabled = false,
placeholder = '搜索并选择',
pageSize = 20,
queryFilter,
searchFields = DEFAULT_SEARCH_FIELDS,
optionsAnchor,
showClearAction = true,
selectedSummaryMode = 'labels',
variant,
}) {
const rawSelectedValues = useMemo(() => {
if (multiple) {
return Array.isArray(value) ? value.filter(Boolean) : [];
}
return value ? [value] : [];
}, [multiple, value]);
const selectedIds = useMemo(
() => uniqIds(rawSelectedValues),
[rawSelectedValues],
);
const [query, setQuery] = useState('');
const [options, setOptions] = useState([]);
const [selectedById, setSelectedById] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [active, setActive] = useState(false);
const lastSearchRequestId = useRef(0);
const lastLookupRequestId = useRef(0);
const lookedUpIdsRef = useRef(new Set());
useEffect(() => {
const selectedOptionsFromValue = rawSelectedValues
.map((item) => createOptionFromReferenceValue(item, subject))
.filter(Boolean);
if (selectedOptionsFromValue.length === 0) {
return;
}
setSelectedById((previous) => {
let changed = false;
const next = { ...previous };
for (const option of selectedOptionsFromValue) {
if (!option?.id) {
continue;
}
if (
next[option.id]?.label === option.label &&
next[option.id]?.description === option.description
) {
continue;
}
next[option.id] = option;
changed = true;
}
return changed ? next : previous;
});
}, [rawSelectedValues, subject]);
useEffect(() => {
const missingIds = selectedIds.filter(
(id) => id && !selectedById[id] && !lookedUpIdsRef.current.has(id),
);
if (!subject || missingIds.length === 0) {
return undefined;
}
let cancelled = false;
const requestId = ++lastLookupRequestId.current;
const fetchSelected = async () => {
try {
missingIds.forEach((id) => lookedUpIdsRef.current.add(id));
const chunks = [];
for (
let i = 0;
i < missingIds.length;
i += DEFAULT_ID_LOOKUP_CHUNK_SIZE
) {
chunks.push(missingIds.slice(i, i + DEFAULT_ID_LOOKUP_CHUNK_SIZE));
}
for (const chunk of chunks) {
const response = await queryRecords({
subject,
page: 0,
page_size: Math.max(chunk.length, 1),
filter: {
_id: { $in: chunk },
},
mode: resolveQueryMode(subject),
});
if (cancelled || requestId !== lastLookupRequestId.current) {
return;
}
const records = response?.records || [];
setSelectedById((previous) => {
const next = { ...previous };
for (const record of records) {
const option = createOptionFromRecord(record, subject);
if (!option?.id) {
continue;
}
next[option.id] = option;
}
return next;
});
}
} catch {
// Ignore: fallback to showing raw ids if lookup fails.
}
};
fetchSelected();
return () => {
cancelled = true;
};
}, [selectedIds, selectedById, subject]);
useEffect(() => {
if (!active) {
return undefined;
}
let cancelled = false;
const requestId = ++lastSearchRequestId.current;
setLoading(true);
setError('');
const timer = setTimeout(async () => {
if (!subject) {
setOptions([]);
setLoading(false);
setError('未配置引用 subject');
return;
}
try {
const searchFilter = buildSearchFilter(
queryFilter,
query,
searchFields,
);
const hasCustomSearchFields = (searchFields || []).length > 0;
const response = await queryRecords({
subject,
page: 0,
page_size: pageSize,
keyword: hasCustomSearchFields
? undefined
: query.trim() || undefined,
mode: resolveQueryMode(subject),
filter: searchFilter,
});
if (cancelled || requestId !== lastSearchRequestId.current) {
return;
}
const records = response?.records || [];
const nextOptions = records
.map((record) => createOptionFromRecord(record, subject))
.filter(Boolean);
setOptions(nextOptions);
} catch (requestError) {
if (!cancelled) {
setError(requestError?.message || '搜索失败');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [active, pageSize, query, queryFilter, searchFields, subject]);
useEffect(() => {
if (options.length === 0) {
return;
}
setSelectedById((previous) => {
const next = { ...previous };
for (const option of options) {
next[option.id] = option;
}
return next;
});
}, [options]);
const selectedOptions = useMemo(() => {
const base = selectedIds.map(
(id) => selectedById[id] || { id, label: id, description: '' },
);
return base.filter(Boolean);
}, [selectedById, selectedIds]);
const menuOptions = useMemo(() => {
if (selectedOptions.length === 0) {
return options;
}
const selectedIdSet = new Set(selectedOptions.map((option) => option.id));
return [
...selectedOptions,
...options.filter((option) => !selectedIdSet.has(option.id)),
];
}, [options, selectedOptions]);
const commitChange = (nextIds) => {
const normalized = uniqIds(nextIds);
if (multiple) {
onChange?.(normalized);
return;
}
onChange?.(normalized[0] || '');
};
const allowClear = showClearAction && !disabled && selectedIds.length > 0;
const clearSelection = () => {
if (disabled) {
return;
}
commitChange([]);
setQuery('');
};
const displayText = multiple && selectedSummaryMode === 'count'
? selectedOptions.length === 0
? placeholder
: `已选择 ${selectedOptions.length} 条记录`
: multiple
? selectedOptions.length === 0
? placeholder
: selectedOptions.length <= 2
? selectedOptions.map((option) => option.label).join('、')
: `${selectedOptions
.slice(0, 2)
.map((option) => option.label)
.join('、')} 等 ${selectedOptions.length} 项`
: selectedOptions[0]?.label || placeholder;
const selectionProps = multiple
? {
selectedKeys: new Set(selectedIds),
onSelectionChange: (keys) => {
const nextIds =
keys === 'all' ? menuOptions.map((item) => item.id) : [...keys];
commitChange(nextIds);
},
}
: {
selectedKey: selectedIds[0] || null,
onSelectionChange: (key) => {
const nextKey = key ? String(key) : '';
commitChange(nextKey ? [String(nextKey)] : []);
setActive(false);
},
};
const handleOpenChange = (isOpen) => {
setActive(isOpen);
if (!isOpen) {
setQuery('');
setError('');
}
};
return (
<XinyuanAutocomplete
{...selectionProps}
aria-label={placeholder}
emptyText="无匹配结果"
errorText={error}
inputValue={query}
isDisabled={disabled}
isLoading={loading}
isOpen={active}
options={menuOptions}
placement={optionsAnchor || 'bottom start'}
placeholder={placeholder}
popoverHeader={allowClear ? (
<div className="flex justify-end px-3 pb-1">
<Button size="sm" variant="tertiary" onPress={clearSelection}>
清空
</Button>
</div>
) : null}
searchPlaceholder={placeholder}
selectionMode={multiple ? 'multiple' : 'single'}
shouldFilter={false}
valueText={displayText}
variant={variant}
onInputChange={setQuery}
onOpenChange={handleOpenChange}
renderOption={(option) => (
<div className="min-w-0 flex-1">
<Label className="block truncate" title={option.label}>
{option.label}
</Label>
{option.description ? (
<div
className="truncate text-xs text-muted"
title={option.description}
>
{option.description}
</div>
) : null}
</div>
)}
/>
);
}
import {
ChevronDown,
Close,
FIELD_SUFFIX_ICON_STYLE,
Search,
} from '@/components/AppIcons';
import {
Autocomplete,
Label,
ListBox,
SearchField,
Spinner,
} from '@heroui/react';
function defaultFilter(textValue, inputValue) {
const query = String(inputValue || '').trim().toLocaleLowerCase();
return !query || String(textValue || '').toLocaleLowerCase().includes(query);
}
function getSelectedKeySet(selectionMode, selectedKey, selectedKeys) {
if (selectionMode === 'multiple') {
return new Set(selectedKeys || []);
}
return new Set(selectedKey === null || selectedKey === undefined ? [] : [selectedKey]);
}
export default function XinyuanAutocomplete({
'aria-label': ariaLabel,
className,
emptyText = '暂无可选项',
errorText = '',
filter,
fullWidth = true,
inputValue,
isDisabled = false,
isLoading = false,
isOpen,
isRequired = false,
name,
onInputChange,
onOpenChange,
onSelectionChange,
options = [],
placeholder = '请选择',
placement = 'bottom start',
popoverHeader,
renderOption,
searchPlaceholder = '输入关键词搜索',
selectedKey,
selectedKeys,
selectionMode = 'single',
shouldFilter = true,
valueText,
variant,
}) {
const selectedKeySet = getSelectedKeySet(
selectionMode,
selectedKey,
selectedKeys,
);
const selectedOptions = options.filter((option) =>
selectedKeySet.has(option.id),
);
const resolvedValueText =
valueText ??
(selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const rootSelectionProps =
selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const visibleOptions = isLoading || errorText ? [] : options;
return (
<Autocomplete
{...rootSelectionProps}
aria-label={ariaLabel || placeholder}
className={className}
fullWidth={fullWidth}
isDisabled={isDisabled}
isOpen={isOpen}
isRequired={isRequired}
name={name}
selectionMode={selectionMode}
variant={variant}
onOpenChange={onOpenChange}
onSelectionChange={onSelectionChange}
>
<Autocomplete.Trigger>
<Autocomplete.Value>
<span
className="block min-w-0 truncate"
title={resolvedValueText}
>
{resolvedValueText}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement={placement}>
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue}
onInputChange={onInputChange}
>
{popoverHeader}
<SearchField
aria-label={searchPlaceholder}
fullWidth
variant={variant}
>
<SearchField.Group>
<SearchField.SearchIcon>
<Search size={16} />
</SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder={searchPlaceholder} />
<SearchField.ClearButton aria-label="清空搜索">
<Close size={14} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox
aria-label={ariaLabel || placeholder}
items={visibleOptions}
renderEmptyState={() => (
<div
className="grid min-h-20 place-items-center px-3 py-4"
data-slot="empty-state"
>
{isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div>
)}
>
{(option) => (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
textValue={option.textValue || option.label}
>
{renderOption ? (
renderOption(option)
) : (
<Label className="truncate" title={option.label}>
{option.label}
</Label>
)}
<ListBox.ItemIndicator />
</ListBox.Item>
)}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
......@@ -175,6 +175,7 @@ function WorkbenchPagination({ pagination }) {
}
export default function RecordWorkbench({
accessDenied = false,
views = EMPTY_ARRAY,
activeViewName,
onViewChange,
......@@ -370,6 +371,9 @@ export default function RecordWorkbench({
) : null}
{states.error ? <Alert status="danger"><Alert.Content><Alert.Description>{states.error}</Alert.Description></Alert.Content></Alert> : null}
<div className="relative min-w-0" ref={gridWrapRef}>
{accessDenied ? (
<DataGridEmpty title="当前账号没有可访问的数据视图" />
) : (
<DataGrid
showSelectionCheckboxes={Boolean(onSelectionChange)}
allowsColumnResize
......@@ -387,12 +391,13 @@ export default function RecordWorkbench({
verticalAlign="middle"
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
/>
)}
{states.loading && records.length > 0 ? <Surface className="absolute inset-0 grid place-items-center" variant="secondary"><Spinner size="sm" /></Surface> : null}
</div>
<WorkbenchPagination pagination={pagination} />
{!accessDenied ? <WorkbenchPagination pagination={pagination} /> : null}
</section>
<ActionBar aria-label="批量操作" isOpen={selectedIds.length > 0}>
<ActionBar aria-label="批量操作" isOpen={!accessDenied && selectedIds.length > 0}>
<ActionBar.Prefix><span className="whitespace-nowrap text-sm text-muted">已选择 {selectedIds.length} 条记录</span></ActionBar.Prefix>
{visibleSelectionActions.length > 0 ? (
<ActionBar.Content>
......
export const APPLICATION_DISPLAY_NAME = '廉洁从业管理';
export const APPLICATION_ROUTE_NAME = 'integrity_practice_management';
export const APPLICATION_BASE_PATH = '/integrity_practice_management/';
export const APPLICATION_NAME = 'integrity_practice_management';
export const APPLICATION_ROUTE_NAME = APPLICATION_NAME;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
......@@ -11,4 +13,5 @@ export const ROUTE_TITLES = Object.freeze({
'/self_check_the_draft_template': '自查底稿模版',
'/self_check_the_working_draft': '自查底稿项目',
'/self_inspection_annual_submission_materials': '历年报送材料',
'/notifications': '通知消息',
});
import clsx from 'clsx';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Surface } from '@heroui/react';
import TailwindEditableField from './TailwindEditableField';
import {
buildViewColumns,
getFieldValue,
isFieldDisabledByView,
isReferenceFieldType,
} from '@/utils/subjectView';
const FIELD_LABEL_OVERRIDES = {
problem_discovery_situation: '问题发现情况',
};
const FULL_WIDTH_FIELD_TYPES = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
'file',
'reference',
'document',
'view',
'template',
'task',
]);
function getFieldName(fieldPath = '') {
return String(fieldPath).replace(/^metadata\./, '').split('.').pop();
}
function getFieldLabel(field) {
const fieldName = getFieldName(field?.field);
const configuredLabel = String(field?.label || '').trim();
const isTechnicalLabel =
!configuredLabel ||
configuredLabel === field?.field ||
configuredLabel === fieldName ||
configuredLabel.startsWith('metadata.');
if (!isTechnicalLabel) {
return configuredLabel;
}
return FIELD_LABEL_OVERRIDES[fieldName] || field?.fieldDef?.label || '其他信息';
}
export default function SubjectViewContent({
view,
subject,
editableRecord,
onFieldUpdate,
editSessionId,
mode = 'view',
onFieldChange,
onPendingLocalMaterialChange,
onFieldValidation,
readonly,
isFieldDisabled,
onOpenReference,
minimal = false,
useAuditMaterialCenter = false,
}) {
const displayFields = buildViewColumns(view, subject);
const fullWidthFieldTypes = new Set([
'long_text',
'rich_text',
'object',
'form',
'subject',
'file',
'reference',
]);
const contentFields = minimal
? (displayFields || []).filter((field) => field.field !== 'title')
: displayFields;
const fieldVariant = 'secondary';
const handleFieldUpdate = (fieldPath, newValue, fieldDef) => {
onFieldUpdate(fieldPath, newValue, fieldDef);
};
if (!contentFields || contentFields.length === 0) {
if (minimal) {
return null;
}
if (!displayFields || displayFields.length === 0) {
return (
<EmptyState size="md">
<EmptyState.Header><EmptyState.Title>暂无可显示的字段</EmptyState.Title></EmptyState.Header>
......@@ -41,43 +78,68 @@ export default function SubjectViewContent({
);
}
return (
<section className="min-w-0">
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
{displayFields.map((field) => {
const value = getFieldValue(editableRecord, field.field);
const fieldDisabled =
isFieldDisabled?.(field.field, view) ||
isFieldDisabledByView(field.field, view);
const isFullWidth =
fullWidthFieldTypes.has(field.fieldDef?.type) ||
isReferenceFieldType(field.fieldDef?.type) ||
field.fieldDef?.multiple && field.fieldDef?.type !== 'select';
const fields = (
<section
className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2"
>
{contentFields.map((field) => {
const value = getFieldValue(editableRecord, field.field);
const fieldType = field.fieldDef?.type || 'text';
const fieldLabel = getFieldLabel(field);
const isFullWidth = FULL_WIDTH_FIELD_TYPES.has(fieldType);
const fieldDisabled = isFieldDisabled
? isFieldDisabled(field.field, view)
: isFieldDisabledByView(field.field, view);
return (
<section
key={field.field}
className={clsx(
'min-w-0 py-3',
isFullWidth && 'md:col-span-2',
)}
>
<div className="text-sm font-medium text-muted">{field.label}</div>
<div className="mt-1.5 min-w-0">
<TailwindEditableField
value={value}
fieldDef={field.fieldDef}
onSave={(newValue) => handleFieldUpdate(field.field, newValue, field.fieldDef)}
mode={mode}
readonly={readonly}
disabled={fieldDisabled}
onOpenReference={onOpenReference}
/>
</div>
</section>
);
})}
</div>
return (
<div
key={field.field}
className={clsx(
'min-w-0',
minimal ? 'py-1' : 'py-3',
isFullWidth && 'md:col-span-2',
)}
>
<div className={minimal ? 'sr-only' : 'text-sm font-medium text-muted'}>
{fieldLabel}
</div>
<div className={minimal ? undefined : 'mt-1.5 min-w-0'}>
<TailwindEditableField
value={value}
fieldDef={field.fieldDef}
fieldPath={field.field}
editSessionId={editSessionId}
fieldVariant={fieldVariant}
placeholder={`请输入${fieldLabel}`}
mode={mode}
onChange={(newValue) =>
onFieldChange?.(
field.field,
newValue,
field.fieldDef,
editSessionId,
)
}
onValidationChange={onFieldValidation}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
readonly={readonly}
disabled={fieldDisabled}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
</div>
</div>
);
})}
</section>
);
if (mode === 'view' && !minimal) {
return (
<Surface className="px-5 py-2 sm:px-6 sm:py-3" variant="secondary">
{fields}
</Surface>
);
}
return fields;
}
import { Calendar as CalendarIcon, ChevronDown, Close, Search, Upload } from '@/components/AppIcons';
import { useEffect, useMemo, useState } from 'react';
import clsx from 'clsx';
import {
Alert,
Button,
Card,
Checkbox,
Chip,
Input,
Label,
TextArea,
} from '@heroui/react';
import FormDatePicker from '@/components/FormDatePicker';
import FileUploadField from '@/components/FileUploadField';
import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import IntegrityPracticeMaterialField from '@/pages/integrity_practice_management/components/IntegrityPracticeMaterialField';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import {
dateInputValue,
formatFieldValue,
getSelectOptions,
isDateFieldType,
isReferenceFieldType,
getReferenceSubjectName,
normalizeUserLabel,
toArray,
} from '@/utils/subjectView';
import { DropZone } from '@heroui-pro/react/drop-zone';
import {
Alert,
Autocomplete,
Calendar,
Checkbox,
Chip,
DateField,
DatePicker,
I18nProvider,
Input,
Label,
ListBox,
SearchField,
Select,
TextArea,
TimeField,
} from '@heroui/react';
import { parseDate, parseDateTime, parseTime } from '@internationalized/date';
import { useEffect, useMemo, useState } from 'react';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import ReferenceSearchSelect from './ReferenceSearchSelect';
function asSaveValue(value, fieldDef) {
if (fieldDef?.multiple) {
return toArray(value).filter((item) => item !== '');
}
return value === '' ? null : value;
if (value === '') {
return null;
}
return value;
}
function getClearedValue(fieldDef) {
return fieldDef?.multiple ? [] : null;
}
function Pill({ children, tone = 'neutral' }) {
const semanticTone = {
amber: 'warning',
emerald: 'success',
rose: 'danger',
zinc: 'neutral',
}[tone] || tone;
return (
<Chip
color={semanticTone === 'neutral' ? undefined : semanticTone}
size="sm"
variant="secondary"
>
{children}
</Chip>
);
}
function FieldError({ children }) {
function FieldError({ children, className }) {
if (!children) return null;
return (
<Alert status="danger">
<Alert.Content><Alert.Description>{children}</Alert.Description></Alert.Content>
<Alert className={className} status="danger">
<Alert.Content>
<Alert.Description>{children}</Alert.Description>
</Alert.Content>
</Alert>
);
}
function isEmptyFieldValue(value) {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.trim() === '';
if (Array.isArray(value)) {
return value.length === 0 || value.every((item) => isEmptyFieldValue(item));
}
return false;
}
function getTextPlaceholder(placeholder, fieldDef) {
const explicitPlaceholder = String(placeholder || '').trim();
if (explicitPlaceholder) return explicitPlaceholder;
const fieldLabel = String(fieldDef?.label || '').trim();
return fieldLabel ? `请输入${fieldLabel}` : '请输入内容';
}
function FieldValue({ value, fieldDef }) {
const isEmpty = value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0);
if (isEmpty) return <span className="text-sm text-muted">未填写</span>;
if (isEmptyFieldValue(value)) {
return <span className="text-sm text-muted">暂无内容</span>;
}
if (fieldDef?.type === 'select') {
const text = formatFieldValue(value, fieldDef);
return (
<div className="flex flex-wrap gap-1.5">
{toArray(text.split('、')).filter(Boolean).map((item) => <Chip key={item} size="sm" variant="secondary">{item}</Chip>)}
{toArray(text.split('、')).filter(Boolean).map((item) => (
<Pill key={item}>{item}</Pill>
))}
</div>
);
}
......@@ -64,7 +111,11 @@ function FieldValue({ value, fieldDef }) {
if (fieldDef?.type === 'user') {
return (
<div className="flex flex-wrap gap-1.5">
{toArray(value).map((item, index) => <Chip color="success" key={`${normalizeUserLabel(item)}-${index}`} size="sm" variant="secondary">{normalizeUserLabel(item) || '-'}</Chip>)}
{toArray(value).map((item, index) => (
<Pill key={`${normalizeUserLabel(item)}-${index}`} tone="success">
{normalizeUserLabel(item) || '-'}
</Pill>
))}
</div>
);
}
......@@ -74,252 +125,793 @@ function FieldValue({ value, fieldDef }) {
<div className="flex flex-wrap gap-2">
{toArray(value).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return file?.url ? <a href={file.url} key={`${label}-${index}`} rel="noreferrer" target="_blank"><Chip color="accent" size="sm" variant="secondary">{label}</Chip></a> : <Chip key={`${label}-${index}`} size="sm" variant="secondary">{label}</Chip>;
if (!file?.url) {
return <Pill key={`${label}-${index}`}>{label}</Pill>;
}
return (
<a
key={`${label}-${index}`}
href={file.url}
target="_blank"
rel="noreferrer"
className="no-underline"
>
<Pill tone="info">{label}</Pill>
</a>
);
})}
</div>
);
}
if (['object', 'form', 'subject'].includes(fieldDef?.type)) {
return <pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs leading-5">{JSON.stringify(value, null, 2)}</pre>;
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') {
return (
<Card variant="tertiary">
<Card.Content>
<pre className="max-h-48 overflow-auto text-xs leading-5">
{JSON.stringify(value, null, 2)}
</pre>
</Card.Content>
</Card>
);
}
return <span className="whitespace-pre-wrap">{formatFieldValue(value, fieldDef) || '-'}</span>;
const text = formatFieldValue(value, fieldDef);
return (
<span className={clsx('whitespace-pre-wrap', isEmptyFieldValue(text) ? 'text-sm text-muted' : 'text-foreground')}>
{isEmptyFieldValue(text) ? '暂无内容' : text}
</span>
);
}
function UserSelector({ value, onChange, multiple }) {
function UserSelector({ value, onChange, multiple, variant }) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const selectedIds = useMemo(() => toArray(value).map((item) => item?._id || item).filter(Boolean).map(String), [value]);
const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value],
);
useEffect(() => {
let cancelled = false;
const loadOptions = async () => {
const filter = keyword.trim() ? { $or: [
{ username: { $regex: keyword.trim(), $options: 'i' } },
{ display_name: { $regex: keyword.trim(), $options: 'i' } },
] } : {};
const filter = keyword.trim()
? {
$or: [
{ username: { $regex: keyword.trim(), $options: 'i' } },
{ display_name: { $regex: keyword.trim(), $options: 'i' } },
],
}
: {};
try {
const result = await queryUsers(0, 20, filter);
if (!cancelled) setOptions(result?.users || []);
} catch {
if (!cancelled) setOptions([]);
if (!cancelled) {
setOptions(result?.users || []);
}
} catch (error) {
if (!cancelled) {
setOptions([]);
}
}
};
loadOptions();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [keyword]);
const selectedOptions = options.filter((user) => selectedIds.includes(String(user._id)));
const displayText = selectedOptions.length > 0
? selectedOptions.map((user) => user.display_name || user.username).join('、')
: selectedIds.length > 0 ? `已选择 ${selectedIds.length} 人` : '请选择用户';
const handleSelectionChange = (keys) => {
if (!multiple) {
onChange(keys ? String(keys) : null);
return;
}
const nextIds = keys === 'all'
? options.map((user) => user._id).filter(Boolean)
: Array.from(keys).map(String);
onChange(nextIds);
};
const autocompleteOptions = options.map((user) => ({
id: user._id,
label: user.display_name || user.username,
description: user.username,
}));
const selectionProps = multiple
? { selectedKeys: new Set(selectedIds) }
: { selectedKey: selectedIds[0] || null };
const valueText = toArray(value)
.map((item) => normalizeUserLabel(item))
.filter(Boolean)
.join('、');
return (
<Autocomplete
aria-label="选择用户"
fullWidth
selectedKey={multiple ? undefined : selectedIds[0] || null}
selectedKeys={multiple ? new Set(selectedIds) : undefined}
<XinyuanAutocomplete
{...selectionProps}
aria-label="用户"
emptyText="暂无用户"
inputValue={keyword}
options={autocompleteOptions}
placeholder="请选择用户"
searchPlaceholder="搜索用户名或显示名"
selectionMode={multiple ? 'multiple' : 'single'}
variant="secondary"
onSelectionChange={(selection) => {
if (multiple) {
onChange(selection === 'all' ? options.map((user) => String(user._id)) : Array.from(selection || []).map(String));
} else {
onChange(selection ? String(selection) : null);
}
}}
>
<Autocomplete.Trigger>
<Autocomplete.Value><span className="block min-w-0 truncate">{displayText}</span></Autocomplete.Value>
<Autocomplete.Indicator><ChevronDown size={16} /></Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement="bottom start">
<Autocomplete.Filter filter={() => true} inputValue={keyword} onInputChange={setKeyword}>
<SearchField aria-label="搜索用户" fullWidth variant="secondary">
<SearchField.Group>
<SearchField.SearchIcon><Search size={16} /></SearchField.SearchIcon>
<SearchField.Input autoFocus placeholder="搜索用户名或显示名" />
<SearchField.ClearButton aria-label="清空搜索"><Close size={14} /></SearchField.ClearButton>
</SearchField.Group>
</SearchField>
<ListBox aria-label="用户候选项" items={options} renderEmptyState={() => <div className="grid min-h-20 place-items-center p-4 text-sm text-muted">暂无用户</div>}>
{(user) => <ListBox.Item id={String(user._id)} textValue={user.display_name || user.username}><span className="min-w-0"><Label className="block truncate">{user.display_name || user.username}</Label><span className="block truncate text-xs text-muted">{user.username}</span></span><ListBox.ItemIndicator /></ListBox.Item>}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
shouldFilter={false}
valueText={valueText || '请选择用户'}
variant={variant}
onInputChange={setKeyword}
onSelectionChange={handleSelectionChange}
renderOption={(option) => (
<span className="min-w-0">
<Label className="block truncate">{option.label}</Label>
<span className="block truncate text-xs text-muted">
{option.description}
</span>
</span>
)}
/>
);
}
function FieldOptionSelect({ fieldDef, value, onChange }) {
const options = getSelectOptions(fieldDef);
const multiple = Boolean(fieldDef?.multiple);
const normalizedValue = multiple
? toArray(value).map(String)
: value === null || value === undefined || value === '' ? null : String(value);
const selectedValues = multiple ? normalizedValue : normalizedValue === null ? [] : [normalizedValue];
const displayText = options.filter((option) => selectedValues.includes(String(option.value))).map((option) => option.label).join('、') || '请选择';
function FieldOptionSelect({ multiple, onChange, options, value, variant }) {
const values = multiple ? toArray(value).map(String) : [String(value || '')];
const selectedOptions = options.filter((option) => values.includes(String(option.value)));
const displayValue = selectedOptions.length
? selectedOptions.map((option) => option.label).join('、')
: '请选择';
const selectProps = multiple
? {
selectedKeys: new Set(values),
onSelectionChange: (keys) =>
onChange(keys === 'all' ? options.map((option) => option.value) : [...keys]),
}
: {
selectedKey: values[0] || null,
onSelectionChange: (key) => onChange(key ? String(key) : ''),
};
return (
<Select
aria-label={fieldDef?.label || fieldDef?.name || '请选择'}
fullWidth
<XinyuanAutocomplete
{...selectProps}
aria-label="选择字段值"
options={options.map((option) => ({
id: String(option.value),
label: option.label,
}))}
placeholder="请选择"
selectionMode={multiple ? 'multiple' : 'single'}
shouldCloseOnSelect={!multiple}
value={normalizedValue}
variant="secondary"
onChange={(nextValue) => onChange(multiple ? (Array.isArray(nextValue) ? nextValue.map(String) : []) : nextValue === null || nextValue === undefined || nextValue === '' ? null : String(nextValue))}
>
<Select.Trigger><Select.Value><span className="block min-w-0 truncate">{displayText}</span></Select.Value><Select.Indicator><ChevronDown size={16} /></Select.Indicator></Select.Trigger>
<Select.Popover placement="bottom start">
<ListBox aria-label={fieldDef?.label || '字段选项'}>
{options.map((option) => <ListBox.Item id={String(option.value)} isDisabled={Boolean(option.disabled)} key={String(option.value)} textValue={String(option.label)}><Label>{option.label}</Label><ListBox.ItemIndicator /></ListBox.Item>)}
</ListBox>
</Select.Popover>
</Select>
valueText={displayValue}
variant={variant}
/>
);
}
function parseDateValue(value, mode) {
if (!value) return null;
try {
return mode === 'datetime' ? parseDateTime(String(value).slice(0, 16)) : parseDate(String(value).slice(0, 10));
} catch {
return null;
function ReadonlyFieldValue({ value, fieldDef }) {
if (isReferenceFieldType(fieldDef?.type)) {
return <ReferenceRecordsTable value={value} fieldDef={fieldDef} />;
}
}
function DateEditor({ value, mode, onChange }) {
const parsedValue = useMemo(() => parseDateValue(value, mode), [mode, value]);
const timeValue = useMemo(() => {
if (!value || !String(value).includes('T')) return null;
try { return parseTime(String(value).slice(11, 16)); } catch { return null; }
}, [value]);
const emitDate = (nextValue) => {
if (!nextValue) return onChange(null);
const dateText = nextValue.toString().slice(0, 10);
const timeText = timeValue?.toString().slice(0, 5) || '00:00';
onChange(mode === 'datetime' ? `${dateText}T${timeText}` : dateText);
};
return (
<I18nProvider locale="zh-CN">
<DatePicker aria-label="选择日期" granularity={mode === 'datetime' ? 'minute' : 'day'} value={parsedValue} onChange={emitDate}>
<DateField.Group fullWidth variant="secondary">
<DateField.Input>{(segment) => <DateField.Segment segment={segment} />}</DateField.Input>
<DateField.Suffix>
<DatePicker.Trigger aria-label="打开日期选择"><DatePicker.TriggerIndicator><CalendarIcon size={16} /></DatePicker.TriggerIndicator></DatePicker.Trigger>
</DateField.Suffix>
</DateField.Group>
<DatePicker.Popover placement="bottom start">
<Calendar>
<Calendar.Header><Calendar.NavButton slot="previous" /><Calendar.Heading /><Calendar.NavButton slot="next" /></Calendar.Header>
<Calendar.Grid><Calendar.GridHeader>{(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}</Calendar.GridHeader><Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody></Calendar.Grid>
</Calendar>
{mode === 'datetime' ? (
<div className="flex items-center justify-between gap-3 p-3">
<span className="text-sm text-muted">时间</span>
<TimeField aria-label="选择时间" granularity="minute" hideTimeZone hourCycle={24} value={timeValue} onChange={(nextTime) => {
const dateText = parsedValue?.toString().slice(0, 10) || String(value || '').slice(0, 10);
if (dateText) onChange(`${dateText}T${nextTime?.toString().slice(0, 5) || '00:00'}`);
}}>
<TimeField.Group fullWidth variant="secondary"><TimeField.Input>{(segment) => <TimeField.Segment segment={segment} />}</TimeField.Input></TimeField.Group>
</TimeField>
</div>
) : null}
</DatePicker.Popover>
</DatePicker>
</I18nProvider>
);
return <FieldValue value={value} fieldDef={fieldDef} />;
}
function FileEditor({ value, fieldDef, onChange }) {
function ControlledFieldEditor({
value,
fieldDef,
fieldPath,
editSessionId,
fieldVariant,
placeholder,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
useAuditMaterialCenter,
}) {
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const textPlaceholder = getTextPlaceholder(placeholder, fieldDef);
const upload = async (files) => {
const handleFileUpload = async (files) => {
const file = files?.[0];
if (!file) return;
setUploading(true);
setError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
try {
const uploaded = await uploadFile(file);
const fileValue = uploaded?.file || uploaded?.data || uploaded;
onChange(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue);
onChange?.(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
}
};
if (fieldType === 'boolean') {
return (
<Checkbox isSelected={Boolean(value)} onChange={(nextValue) => onChange?.(nextValue)}>
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
{value ? '是' : '否'}
</Checkbox.Content>
</Checkbox>
);
}
if (fieldType === 'select') {
return (
<FieldOptionSelect
multiple={Boolean(fieldDef?.multiple)}
onChange={onChange}
options={getSelectOptions(fieldDef)}
value={value}
variant={fieldVariant}
/>
);
}
if (fieldType === 'user') {
return (
<UserSelector
value={value}
onChange={onChange}
multiple={Boolean(fieldDef?.multiple)}
variant={fieldVariant}
/>
);
}
if (isReferenceFieldType(fieldType)) {
const referenceSubject = getReferenceSubjectName(fieldDef);
return (
<ReferenceSearchSelect
subject={referenceSubject}
value={value}
onChange={onChange}
multiple={Boolean(fieldDef?.multiple)}
placeholder="搜索并选择关联记录"
optionsAnchor="bottom start"
selectedSummaryMode="labels"
showClearAction
variant={fieldVariant}
/>
);
}
if (fieldType === 'file') {
if (useAuditMaterialCenter) {
return (
<IntegrityPracticeMaterialField
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">
{toArray(value).length === 0 ? (
<span className="text-sm text-muted">未上传</span>
) : (
<div className="space-y-2">
{toArray(value).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return (
<div key={`${label}-${index}`} className="flex items-center justify-between gap-3">
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{label}</span>
<Button
size="sm"
variant="danger-soft"
onPress={() =>
onChange?.(
fieldDef?.multiple
? toArray(value).filter((_, fileIndex) => fileIndex !== index)
: null,
)
}
>
删除
</Button>
</div>
);
})}
</div>
)}
<FileUploadField
isDisabled={uploading}
onFilesChange={handleFileUpload}
description="上传该字段的附件。"
triggerLabel="选择附件"
/>
<FieldError>{error}</FieldError>
</Card.Content>
</Card>
);
}
if (fieldType === 'form' || fieldType === 'subject') {
return <FieldValue value={value} fieldDef={fieldDef} />;
}
if (fieldType === 'object') {
const jsonValue = typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2);
return (
<div className="space-y-2">
<TextArea
fullWidth
rows={8}
value={jsonValue}
variant={fieldVariant}
onChange={(event) => {
const nextValue = event.target.value;
onChange?.(nextValue);
if (!nextValue.trim()) {
setError('');
onValidationChange?.(fieldPath, '', editSessionId);
return;
}
try {
JSON.parse(nextValue);
setError('');
onValidationChange?.(fieldPath, '', editSessionId);
} catch (parseError) {
const message = 'JSON 格式不正确,请检查后再保存。';
setError(message);
onValidationChange?.(fieldPath, message, editSessionId);
}
}}
/>
<FieldError>{error}</FieldError>
</div>
);
}
if (isLongText) {
return (
<TextArea
fullWidth
placeholder={textPlaceholder}
rows={5}
value={value || ''}
variant={fieldVariant}
onChange={(event) => onChange?.(event.target.value)}
/>
);
}
if (isDateField) {
return (
<FormDatePicker
value={dateInputValue(value)}
variant={fieldVariant}
onChange={(event) => onChange?.(event.target.value)}
/>
);
}
return (
<div className="space-y-3">
{toArray(value).length > 0 ? (
<DropZone.FileList>
{toArray(value).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return <DropZone.FileItem key={`${label}-${index}`} status="complete"><DropZone.FileFormatIcon format={String(label).split('.').pop()?.slice(0, 4).toUpperCase()} /><DropZone.FileInfo><DropZone.FileName>{label}</DropZone.FileName><DropZone.FileMeta>已上传</DropZone.FileMeta></DropZone.FileInfo><DropZone.FileRemoveTrigger aria-label={`删除${label}`} onPress={() => onChange(fieldDef?.multiple ? toArray(value).filter((_, fileIndex) => fileIndex !== index) : null)}><Close size={14} /></DropZone.FileRemoveTrigger></DropZone.FileItem>;
})}
</DropZone.FileList>
) : null}
<DropZone>
<DropZone.Area aria-label="选择附件" isDisabled={uploading} onDrop={async (event) => {
const items = Array.from(event?.items || []).filter((item) => item?.kind === 'file' && typeof item.getFile === 'function');
upload(await Promise.all(items.map((item) => item.getFile())));
}}>
<DropZone.Input disabled={uploading} multiple={Boolean(fieldDef?.multiple)} onSelect={(files) => upload(Array.from(files || []))} />
<DropZone.Icon><Upload size={20} /></DropZone.Icon>
<DropZone.Label>{uploading ? '附件上传中' : '将文件拖到此处'}</DropZone.Label>
<DropZone.Description>将文件拖到此处,或从本地选择。</DropZone.Description>
<DropZone.Trigger isDisabled={uploading}><Upload size={16} />选择附件</DropZone.Trigger>
</DropZone.Area>
</DropZone>
<FieldError>{error}</FieldError>
</div>
<Input
fullWidth
placeholder={textPlaceholder}
type={fieldType === 'number' ? 'number' : 'text'}
value={value ?? ''}
variant={fieldVariant}
onChange={(event) => {
const nextValue = fieldType === 'number'
? event.target.value === '' ? '' : Number(event.target.value)
: event.target.value;
onChange?.(nextValue);
}}
/>
);
}
function ControlledFieldEditor({ value, fieldDef, disabled, onChange }) {
export default function TailwindEditableField({
value,
fieldDef,
fieldPath,
editSessionId,
fieldVariant = 'secondary',
placeholder,
onSave,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
useAuditMaterialCenter = false,
mode = 'inline',
readonly = false,
disabled = false,
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState('');
const [jsonDraft, setJsonDraft] = useState(() => typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2));
const fieldType = fieldDef?.type || 'text';
const [uploading, setUploading] = useState(false);
useEffect(() => {
setJsonDraft(typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2));
setDraft(value);
}, [value]);
if (disabled) return <div className="space-y-2"><FieldValue fieldDef={fieldDef} value={value} /><div className="text-xs text-warning">当前视图无编辑权限</div></div>;
if (fieldType === 'boolean') return <Checkbox isSelected={Boolean(value)} onChange={onChange}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{value ? '是' : '否'}</Checkbox.Content></Checkbox>;
if (fieldType === 'select') return <FieldOptionSelect fieldDef={fieldDef} onChange={onChange} value={value} />;
if (fieldType === 'user') return <UserSelector multiple={Boolean(fieldDef?.multiple)} onChange={onChange} value={value} />;
if (isReferenceFieldType(fieldType)) return <ReferenceSearchSelect fieldDef={fieldDef} onChange={onChange} value={value} />;
if (fieldType === 'file') return <FileEditor fieldDef={fieldDef} onChange={onChange} value={value} />;
if (['form', 'subject'].includes(fieldType)) return <div className="space-y-2"><FieldValue fieldDef={fieldDef} value={value} /><div className="text-xs text-muted">该结构字段仅支持查看</div></div>;
if (fieldType === 'object') return <div className="space-y-2"><TextArea fullWidth rows={8} value={jsonDraft} variant="secondary" onChange={(event) => {
const nextValue = event.target.value;
setJsonDraft(nextValue);
try {
onChange(nextValue.trim() ? JSON.parse(nextValue) : null);
setError('');
} catch {
setError('JSON 格式不正确');
useEffect(() => {
if (readonly || disabled) {
setEditing(false);
}
}} /><FieldError>{error}</FieldError></div>;
if (['long_text', 'rich_text'].includes(fieldType)) return <TextArea fullWidth rows={5} value={value || ''} variant="secondary" onChange={(event) => onChange(event.target.value)} />;
if (isDateFieldType(fieldType)) return <DateEditor mode={fieldType === 'date' ? 'date' : 'datetime'} onChange={onChange} value={fieldType === 'date' ? dateInputValue(value) : value} />;
return <Input fullWidth type={fieldType === 'number' ? 'number' : 'text'} value={value ?? ''} variant="secondary" onChange={(event) => onChange(fieldType === 'number' ? event.target.value === '' ? '' : Number(event.target.value) : event.target.value)} />;
}
}, [readonly, disabled]);
export default function TailwindEditableField({ value, fieldDef, onSave, mode, readonly = false, disabled = false, onOpenReference }) {
const canEdit = !readonly && !disabled;
const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const isJsonLike = ['object', 'form', 'subject'].includes(fieldType);
const textPlaceholder = getTextPlaceholder(placeholder, fieldDef);
if (mode === 'view') {
if (isReferenceFieldType(fieldType)) return <ReferenceRecordsTable fieldDef={fieldDef} onOpenReference={onOpenReference} value={value} />;
return <FieldValue fieldDef={fieldDef} value={value} />;
if (mode === 'view' || (mode === 'edit' && (readonly || disabled))) {
return <ReadonlyFieldValue value={value} fieldDef={fieldDef} />;
}
return <ControlledFieldEditor disabled={readonly || disabled} fieldDef={fieldDef} value={value} onChange={(nextValue) => onSave(asSaveValue(nextValue, fieldDef))} />;
if (mode === 'edit') {
return (
<ControlledFieldEditor
value={value}
fieldDef={fieldDef}
fieldPath={fieldPath}
editSessionId={editSessionId}
fieldVariant={fieldVariant}
placeholder={textPlaceholder}
onChange={onChange}
onPendingLocalMaterialChange={onPendingLocalMaterialChange}
onValidationChange={onValidationChange}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
);
}
const save = async (nextValue = draft) => {
setError('');
if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) {
try {
nextValue = JSON.parse(nextValue);
} catch (parseError) {
setError('JSON 格式不正确,请检查后再保存。');
return;
}
}
try {
await onSave(asSaveValue(nextValue, fieldDef));
setEditing(false);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
}
};
const clearValue = async () => {
const clearedValue = getClearedValue(fieldDef);
setDraft(clearedValue);
await save(clearedValue);
};
const cancel = () => {
setDraft(value);
setError('');
setEditing(false);
};
const handleFileUpload = async (files) => {
const file = files?.[0];
if (!file) {
return;
}
setUploading(true);
setError('');
try {
const uploaded = await uploadFile(file);
const fileValue = uploaded?.file || uploaded?.data || uploaded;
const nextValue = fieldDef?.multiple ? [...toArray(draft), fileValue] : fileValue;
setDraft(nextValue);
await onSave(nextValue);
setEditing(false);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
}
};
const handleRemoveFile = async (indexToRemove) => {
setError('');
const nextValue = fieldDef?.multiple
? toArray(draft).filter((_, index) => index !== indexToRemove)
: null;
try {
setDraft(nextValue);
await onSave(nextValue);
if (!fieldDef?.multiple) {
setEditing(false);
}
} catch (saveError) {
setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。');
}
};
if (!editing) {
if (isReferenceFieldType(fieldType)) {
return (
<div>
<ReferenceRecordsTable
value={value}
fieldDef={fieldDef}
action={
canEdit ? (
<Button size="sm" variant="secondary" onPress={() => setEditing(true)}>
编辑关联记录
</Button>
) : null
}
/>
{disabled ? <div className="mt-2 text-xs text-warning">当前视图无编辑权限</div> : null}
</div>
);
}
return (
<Button
type="button"
isDisabled={!canEdit}
onPress={() => canEdit && setEditing(true)}
title={canEdit ? '点击编辑' : undefined}
variant="ghost"
className={clsx('w-full justify-start text-left', !canEdit && 'cursor-default')}
>
<FieldValue value={value} fieldDef={fieldDef} />
{disabled ? (
<div className="mt-2 text-xs text-warning">当前视图无编辑权限</div>
) : null}
</Button>
);
}
if (fieldType === 'boolean') {
return (
<div className="flex flex-wrap items-center gap-3">
<Checkbox isSelected={Boolean(draft)} onChange={setDraft}>
<Checkbox.Content>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
{draft ? '是' : '否'}
</Checkbox.Content>
</Checkbox>
<Button size="sm" variant="tertiary" onPress={clearValue} className="ml-auto">
清空
</Button>
<Button size="sm" onPress={() => save(Boolean(draft))}>
保存
</Button>
<Button size="sm" variant="secondary" onPress={cancel}>
取消
</Button>
<FieldError className="basis-full">{error}</FieldError>
</div>
);
}
if (fieldType === 'select') {
const options = getSelectOptions(fieldDef);
return (
<div className="space-y-3">
<FieldOptionSelect
multiple={Boolean(fieldDef?.multiple)}
onChange={setDraft}
options={options}
value={draft}
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="tertiary" onPress={clearValue}>清空</Button>
<Button size="sm" variant="secondary" onPress={cancel}>取消</Button>
<Button size="sm" onPress={() => save()}>保存</Button>
</div>
<FieldError>{error}</FieldError>
</div>
);
}
if (fieldType === 'user') {
return (
<div className="space-y-3">
<UserSelector value={draft} onChange={setDraft} multiple={fieldDef?.multiple} />
<div className="flex justify-end gap-2">
<Button size="sm" variant="tertiary" onPress={clearValue}>清空</Button>
<Button size="sm" variant="secondary" onPress={cancel}>取消</Button>
<Button size="sm" onPress={() => save()}>保存</Button>
</div>
<FieldError>{error}</FieldError>
</div>
);
}
if (isReferenceFieldType(fieldType)) {
const referenceSubject = getReferenceSubjectName(fieldDef);
return (
<div className="space-y-3">
<ReferenceSearchSelect
subject={referenceSubject}
value={draft}
onChange={setDraft}
multiple={Boolean(fieldDef?.multiple)}
placeholder="搜索并选择关联记录"
optionsAnchor="bottom start"
selectedSummaryMode="labels"
showClearAction
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="tertiary" onPress={clearValue}>清空</Button>
<Button size="sm" variant="secondary" onPress={cancel}>取消</Button>
<Button size="sm" onPress={() => save()}>保存</Button>
</div>
<FieldError>{error}</FieldError>
</div>
);
}
if (fieldType === 'file') {
return (
<Card variant="secondary">
<Card.Content className="space-y-3">
<div className="space-y-2">
{toArray(draft).length === 0 ? (
<span className="text-muted">未上传</span>
) : (
toArray(draft).map((file, index) => {
const label = file?.name || file?.title || file?.url || `附件 ${index + 1}`;
return (
<div
key={`${label}-${index}`}
className="flex items-center justify-between gap-3"
>
<div className="min-w-0 flex-1">
{file?.url ? (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-accent transition hover:text-accent-hover"
>
{label}
</a>
) : (
<div className="text-sm font-medium text-foreground">{label}</div>
)}
</div>
<Button
size="sm"
variant="danger-soft"
onPress={() => handleRemoveFile(index)}
>
删除
</Button>
</div>
);
})
)}
</div>
<FileUploadField
isDisabled={uploading}
onFilesChange={handleFileUpload}
description="上传该字段的附件。"
triggerLabel="选择附件"
/>
<FieldError>{error}</FieldError>
<div className="flex justify-end gap-2">
<Button size="sm" variant="tertiary" onPress={clearValue}>清空</Button>
<Button size="sm" variant="secondary" onPress={cancel}>取消</Button>
<span className="text-sm text-muted">{uploading ? '上传中...' : '选择文件后自动保存'}</span>
</div>
</Card.Content>
</Card>
);
}
if (isJsonLike) {
const jsonValue = typeof draft === 'string' ? draft : JSON.stringify(draft ?? null, null, 2);
return (
<div className="space-y-3">
<TextArea
fullWidth
rows={8}
value={jsonValue}
variant="secondary"
onChange={(event) => setDraft(event.target.value)}
/>
<FieldError>{error}</FieldError>
<div className="flex justify-end gap-2">
<Button size="sm" variant="tertiary" onPress={clearValue}>清空</Button>
<Button size="sm" variant="secondary" onPress={cancel}>取消</Button>
<Button
size="sm"
onPress={() => (fieldType === 'object' ? save() : cancel())}
>
{fieldType === 'object' ? '保存' : '关闭'}
</Button>
</div>
</div>
);
}
if (isLongText) {
return (
<div className="space-y-3">
<TextArea
autoFocus
fullWidth
placeholder={textPlaceholder}
rows={5}
value={draft || ''}
variant="secondary"
onChange={(event) => setDraft(event.target.value)}
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="tertiary" onPress={clearValue}>清空</Button>
<Button size="sm" variant="secondary" onPress={cancel}>取消</Button>
<Button size="sm" onPress={() => save()}>保存</Button>
</div>
<FieldError>{error}</FieldError>
</div>
);
}
return (
<div className="space-y-3">
{isDateField ? (
<FormDatePicker
value={dateInputValue(draft)}
onChange={(event) => setDraft(event.target.value)}
/>
) : (
<Input
autoFocus
fullWidth
placeholder={textPlaceholder}
type={fieldType === 'number' ? 'number' : 'text'}
value={draft ?? ''}
variant="secondary"
onChange={(event) => {
const nextValue = fieldType === 'number'
? event.target.value === '' ? '' : Number(event.target.value)
: event.target.value;
setDraft(nextValue);
}}
/>
)}
<div className="flex justify-end gap-2">
<Button size="sm" variant="tertiary" onPress={clearValue}>清空</Button>
<Button size="sm" variant="secondary" onPress={cancel}>取消</Button>
<Button size="sm" onPress={() => save()}>保存</Button>
</div>
<FieldError>{error}</FieldError>
</div>
);
}
import { useEffect, useRef, useState } from 'react';
import {
getSubjectView,
getSubjectViewDetail,
loadRecord,
loadSubject,
updateRecord,
......@@ -37,6 +38,14 @@ function transformSaveValue(newValue, fieldDef) {
return newValue === '' ? null : Number(newValue);
}
if (fieldDef?.type === 'object' && typeof newValue === 'string') {
try {
return JSON.parse(newValue);
} catch (error) {
throw new Error(`${fieldDef?.label || '对象字段'}的 JSON 格式不正确,请检查后再保存。`);
}
}
return newValue;
}
......@@ -66,10 +75,8 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const requestIdRef = useRef(0);
const pendingPayloadRef = useRef({});
useEffect(() => {
let cancelled = false;
......@@ -91,14 +98,31 @@ export function useSubjectRecord(
try {
const record = await loadRecord(recordName);
const resolvedSubjectName = subjectName || record?.__t || record?.subject?.name || record?.subject;
const [subjectData, viewsData] = resolvedSubjectName
const [subjectData, viewSummaries] = resolvedSubjectName
? await Promise.all([loadSubject(resolvedSubjectName), getSubjectView(resolvedSubjectName)])
: [null, []];
const viewsData = await Promise.all(
(Array.isArray(viewSummaries) ? viewSummaries : []).map(async (view) => {
if (!view?.name || !resolvedSubjectName) {
return view;
}
try {
const detail = await getSubjectViewDetail(resolvedSubjectName, view.name);
return {
...view,
...detail,
settings: detail?.settings || view?.settings || {},
};
} catch (error) {
return view;
}
}),
);
if (!cancelled && requestIdRef.current === requestId) {
setRemoteRecord(record);
setEditableRecord(JSON.parse(JSON.stringify(record)));
pendingPayloadRef.current = {};
setSubject(subjectData);
setViews(viewsData || []);
}
......@@ -124,50 +148,51 @@ export function useSubjectRecord(
};
}, [recordName, refreshKey, subjectName]);
const handleFieldUpdate = (fieldPath, newValue, fieldDef) => {
const handleFieldsUpdate = async (changes = []) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
return;
}
let nextRecord = setFieldValue(editableRecord, fieldPath, newValue);
const saveValue = transformSaveValue(newValue, fieldDef);
let payload = buildNestedPayload(fieldPath, saveValue);
const derivedFieldValues = deriveFieldValues?.({
fieldPath,
fieldDef,
newValue: saveValue,
record: nextRecord,
}) || {};
Object.entries(derivedFieldValues).forEach(([derivedFieldPath, derivedValue]) => {
nextRecord = setFieldValue(nextRecord, derivedFieldPath, derivedValue);
payload = mergePayload(payload, buildNestedPayload(derivedFieldPath, derivedValue));
});
pendingPayloadRef.current = mergePayload(pendingPayloadRef.current, payload);
setEditableRecord(nextRecord);
};
const resetDraft = () => {
pendingPayloadRef.current = {};
setEditableRecord(remoteRecord ? JSON.parse(JSON.stringify(remoteRecord)) : null);
};
const normalizedChanges = changes.filter((change) => change?.fieldPath);
if (normalizedChanges.length === 0) {
return;
}
const saveDraft = async () => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) return;
const payload = pendingPayloadRef.current;
if (Object.keys(payload).length === 0) return;
const previousRecord = editableRecord;
let nextRecord = editableRecord;
let payload = {};
setSaving(true);
try {
normalizedChanges.forEach(({ fieldPath, newValue, fieldDef }) => {
const saveValue = transformSaveValue(newValue, fieldDef);
nextRecord = setFieldValue(nextRecord, fieldPath, saveValue);
payload = mergePayload(payload, buildNestedPayload(fieldPath, saveValue));
const derivedFieldValues = deriveFieldValues?.({
fieldPath,
fieldDef,
newValue: saveValue,
record: nextRecord,
}) || {};
Object.entries(derivedFieldValues).forEach(([derivedFieldPath, derivedValue]) => {
nextRecord = setFieldValue(nextRecord, derivedFieldPath, derivedValue);
payload = mergePayload(payload, buildNestedPayload(derivedFieldPath, derivedValue));
});
});
setEditableRecord(nextRecord);
await updateRecord(recordName, payload);
setRemoteRecord(JSON.parse(JSON.stringify(editableRecord)));
pendingPayloadRef.current = {};
} finally {
setSaving(false);
setRemoteRecord(nextRecord);
} catch (updateError) {
setEditableRecord(previousRecord);
throw updateError;
}
};
const handleFieldUpdate = (fieldPath, newValue, fieldDef) =>
handleFieldsUpdate([{ fieldPath, newValue, fieldDef }]);
const isFieldDisabled = (fieldPath, currentView) => {
return (
fieldPath === 'is_locked' ||
......@@ -184,9 +209,7 @@ export function useSubjectRecord(
views,
error,
handleFieldUpdate,
resetDraft,
saveDraft,
saving,
handleFieldsUpdate,
isFieldDisabled,
loading,
};
......
import { Alert, Button, Modal, Surface, Tabs, Toast, toast } from '@heroui/react';
import FullscreenExitOutlined from '@ant-design/icons/FullscreenExitOutlined';
import FullscreenOutlined from '@ant-design/icons/FullscreenOutlined';
import { Close } from '@/components/AppIcons';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip } from '@heroui/react';
import React, {
useCallback,
useContext,
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { Close } from '@/components/AppIcons';
import { useSubjectRecord } from './hooks/useSubjectRecord';
import SubjectViewContent from './components/SubjectViewContent';
import { UserContext } from '@/wrapper/Auth';
import { createIntegrityPracticeMaterial } from '@/pages/integrity_practice_management/integrityPracticeMaterialService';
import {
buildViewColumns,
checkViewCondition,
isFieldDisabledByView,
isViewAccessible,
getFieldValue,
hasFieldAuthorizationConfiguration,
setFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
const EMPTY_ARRAY = [];
const NON_EDITABLE_FIELD_TYPES = new Set(['form', 'subject']);
function cloneRecord(record) {
return record ? JSON.parse(JSON.stringify(record)) : null;
}
function fieldValuesEqual(left, right, fieldDef) {
let normalizedRight = right;
if (fieldDef?.type === 'object' && typeof right === 'string' && right.trim()) {
try {
normalizedRight = JSON.parse(right);
} catch (error) {
return false;
}
}
return JSON.stringify(left ?? null) === JSON.stringify(normalizedRight ?? null);
}
const RecordDetailDrawer = (props, ref) => {
const {
open,
recordName,
subjectName,
title,
minimalContent = false,
onClose,
onDataChange,
readonly = false,
......@@ -30,15 +61,24 @@ const RecordDetailDrawer = (props, ref) => {
disabledFields = EMPTY_ARRAY,
authorizationView = null,
deriveFieldValues,
useAuditMaterialCenter = false,
initialMode = 'view',
} = props;
const currentUser = useContext(UserContext);
const [refreshKey, setRefreshKey] = useState(() => Date.now());
const [isFullscreen, setIsFullscreen] = useState(false);
const [activeTabKey, setActiveTabKey] = useState();
const [mode, setMode] = useState('view');
const [activeRecordName, setActiveRecordName] = useState(recordName);
const [activeSubjectName, setActiveSubjectName] = useState(subjectName);
const [referenceStack, setReferenceStack] = useState([]);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [draftRecord, setDraftRecord] = useState(null);
const [dirtyFields, setDirtyFields] = useState({});
const [validationErrors, setValidationErrors] = useState({});
const [saveError, setSaveError] = useState('');
const editSessionRef = useRef(0);
const initialModeAppliedRef = useRef('');
const pendingLocalMaterialsRef = useRef({});
const {
editableRecord,
......@@ -46,35 +86,60 @@ const RecordDetailDrawer = (props, ref) => {
subject,
views,
error,
handleFieldUpdate,
resetDraft,
saveDraft,
saving,
handleFieldsUpdate,
isFieldDisabled,
loading,
} = useSubjectRecord(
activeRecordName,
recordName,
refreshKey,
readonly,
activeSubjectName,
subjectName,
deriveFieldValues,
);
useEffect(() => {
if (open) {
setRefreshKey(Date.now());
setMode(initialMode === 'edit' ? 'edit' : 'view');
setActiveRecordName(recordName);
setActiveSubjectName(subjectName);
setReferenceStack([]);
setIsFullscreen(false);
}
setIsEditing(false);
setIsSaving(false);
setDraftRecord(null);
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
initialModeAppliedRef.current = '';
editSessionRef.current += 1;
}, [open, recordName]);
useEffect(() => {
const openingKey = `${recordName}:edit`;
if (
open &&
initialMode === 'edit' &&
editableRecord &&
!readonly &&
initialModeAppliedRef.current !== openingKey
) {
initialModeAppliedRef.current = openingKey;
handleStartEditing();
}
}, [initialMode, open, recordName, subjectName]);
// Opening mode is applied once after the record has loaded.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editableRecord, open, recordName]);
useEffect(() => {
if (!isEditing) {
setDraftRecord(cloneRecord(editableRecord));
}
}, [editableRecord, isEditing]);
const detailViews = useMemo(() => {
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) {
......@@ -122,12 +187,9 @@ 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 isReferenceDetail = referenceStack.length > 0;
const effectiveReadonly = readonly || isRecordLocked || isReferenceDetail;
useEffect(() => {
if (effectiveReadonly && mode === 'edit') setMode('view');
}, [effectiveReadonly, mode]);
const effectiveReadonly = readonly || isRecordLocked;
const disabledFieldSet = useMemo(
() =>
new Set(
......@@ -135,98 +197,324 @@ const RecordDetailDrawer = (props, ref) => {
),
[disabledFields],
);
const isDrawerFieldDisabled = (fieldPath, currentView) =>
disabledFieldSet.has(fieldPath) ||
isFieldDisabled(fieldPath, currentView) ||
isFieldDisabledByView(fieldPath, authorizationView);
const closeDrawer = () => {
resetDraft();
setMode('view');
if (referenceStack.length > 0) {
const previous = referenceStack[referenceStack.length - 1];
setReferenceStack((current) => current.slice(0, -1));
setActiveRecordName(previous.recordName);
setActiveSubjectName(previous.subjectName);
setRefreshKey(Date.now());
return;
}
const isDrawerFieldDisabled = (fieldPath, currentView) => {
const effectiveAuthorizationView = hasFieldAuthorizationConfiguration(currentView)
? currentView
: authorizationView || currentView;
return (
disabledFieldSet.has(fieldPath) ||
isFieldDisabled(fieldPath, effectiveAuthorizationView)
);
};
const hasEditableFields =
Boolean(editableRecord) &&
!loading &&
!error &&
!effectiveReadonly &&
!minimalContent &&
buildViewColumns(activeView, subject).some(
(field) =>
!NON_EDITABLE_FIELD_TYPES.has(field.fieldDef?.type) &&
!isDrawerFieldDisabled(field.field, activeView),
);
const hasDirtyFields = Object.keys(dirtyFields).length > 0;
const hasValidationErrors = Object.values(validationErrors).some(Boolean);
const displayedRecord = isEditing ? draftRecord || editableRecord : editableRecord;
const resetEditSession = () => {
editSessionRef.current += 1;
setIsEditing(false);
setIsSaving(false);
setDraftRecord(cloneRecord(editableRecord));
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
};
const handleRequestClose = () => {
resetEditSession();
onClose?.();
};
const openReference = (nextReference) => {
setReferenceStack((current) => [...current, { recordName: activeRecordName, subjectName: activeSubjectName }]);
setActiveRecordName(nextReference.recordName);
setActiveSubjectName(nextReference.subjectName);
setMode('view');
setRefreshKey(Date.now());
const handleStartEditing = () => {
if (!hasEditableFields) return;
editSessionRef.current += 1;
setDraftRecord(cloneRecord(editableRecord));
setDirtyFields({});
setValidationErrors({});
setSaveError('');
pendingLocalMaterialsRef.current = {};
setIsEditing(true);
};
const cancelEdit = () => {
resetDraft();
setMode('view');
const handleDraftFieldChange = (fieldPath, newValue, fieldDef, editSessionId) => {
if (!isEditing || editSessionId !== editSessionRef.current) return;
setDraftRecord((currentRecord) =>
setFieldValue(currentRecord || editableRecord, fieldPath, newValue),
);
setDirtyFields((currentFields) => {
const nextFields = { ...currentFields };
const originalValue = getFieldValue(editableRecord, fieldPath);
if (fieldValuesEqual(originalValue, newValue, fieldDef)) {
delete nextFields[fieldPath];
} else {
nextFields[fieldPath] = { fieldPath, newValue, fieldDef };
}
return nextFields;
});
setSaveError('');
};
const saveChanges = async () => {
const handleFieldValidation = useCallback((fieldPath, message, editSessionId) => {
if (editSessionId !== editSessionRef.current) return;
setValidationErrors((currentErrors) => {
if (currentErrors[fieldPath] === message) {
return currentErrors;
}
const nextErrors = { ...currentErrors };
if (message) {
nextErrors[fieldPath] = message;
} else {
delete nextErrors[fieldPath];
}
return nextErrors;
});
}, []);
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;
setIsSaving(true);
setSaveError('');
try {
await saveDraft();
setMode('view');
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 createIntegrityPracticeMaterial({
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);
toast.success('记录已保存', { timeout: 3500 });
} catch (saveError) {
toast.danger(saveError?.message || '保存失败,请稍后重试', { timeout: 4500 });
} catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
} finally {
setIsSaving(false);
}
};
const recordTitle =
displayedRecord?.title ||
displayedRecord?.display_name ||
displayedRecord?.name ||
recordName ||
'记录';
return (
<Modal.Root>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop isOpen={open} variant="blur" onOpenChange={(isOpen) => !isOpen && closeDrawer()}>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="rc-detail-dialog rc-dialog-panel--4xl">
<Modal.CloseTrigger aria-label="关闭"><Close size={16} /></Modal.CloseTrigger>
<Modal.Header>
<Modal.Heading>{mode === 'edit' ? '编辑记录' : referenceStack.length > 0 ? '关联记录详情' : '记录详情'}</Modal.Heading>
</Modal.Header>
<Modal.Body>
<Toast.Provider placement="top" />
{detailViews.length > 1 ? (
<div className="max-w-full overflow-x-auto">
<Tabs className="w-fit max-w-full" selectedKey={activeView?.name} onSelectionChange={(key) => setActiveTabKey(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full">
<Tabs.List aria-label="详情视图" className="min-w-max">
{detailViews.map((view) => <Tabs.Tab className="flex-none whitespace-nowrap" id={view.name} key={view.name}>{view.title || view.name}<Tabs.Indicator /></Tabs.Tab>)}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<Modal.Backdrop
isOpen={Boolean(open)}
isDismissable={!hasDirtyFields && !isSaving}
isKeyboardDismissDisabled={hasDirtyFields || isSaving}
onOpenChange={(isOpen) => {
if (!isOpen) handleRequestClose();
}}
variant="blur"
>
<Modal.Container placement="center" scroll="inside" size={isFullscreen ? 'full' : 'lg'}>
<Modal.Dialog className={isFullscreen ? undefined : 'p-7 sm:max-w-[68rem]'}>
<div className="absolute right-7 top-7 z-10 flex items-center gap-2">
{!minimalContent ? (
<Tooltip>
<Button
aria-label={isFullscreen ? '退出全屏' : '全屏查看'}
className="size-6 min-w-6 p-0"
isIconOnly
size="sm"
variant="ghost"
onPress={() => setIsFullscreen((current) => !current)}
>
{isFullscreen ? (
<FullscreenExitOutlined className="block" aria-hidden="true" />
) : (
<FullscreenOutlined className="block" aria-hidden="true" />
)}
</Button>
<Tooltip.Content>{isFullscreen ? '退出全屏' : '全屏查看'}</Tooltip.Content>
</Tooltip>
) : null}
{!isEditing ? (
<Modal.CloseTrigger className="static" aria-label="关闭">
<Close size={16} />
</Modal.CloseTrigger>
) : null}
</div>
<Modal.Header className="pr-16">
<Modal.Heading>{title || '记录详情'}</Modal.Heading>
{!hideRecordTitle ? (
<div className="truncate text-sm text-muted" title={recordTitle}>
{loading ? '正在读取记录信息' : recordTitle}
</div>
) : null}
</Modal.Header>
{detailViews.length > 1 ? (
<Tabs
aria-label="详情视图"
className="w-fit max-w-full min-w-0"
selectedKey={activeView?.name}
variant="secondary"
onSelectionChange={setActiveTabKey}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
{detailViews.map((view) => (
<Tabs.Tab
className="w-auto min-w-max flex-none whitespace-nowrap"
id={view.name}
key={view.name}
>
{view.title || view.name}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
) : null}
<Modal.Body>
{loading ? (
<div className="grid min-h-48 place-items-center text-sm text-muted">正在加载详情...</div>
<div className="flex min-h-48 items-center justify-center" role="status">
<Spinner size="sm" />
</div>
) : error ? (
<Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
<Alert status="danger">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
) : (
<div className="space-y-4">
{isRecordLocked ? <Alert status="warning"><Alert.Content><Alert.Description>当前问题整改记录已锁定,仅可查看。如需修改,请先在问题清单中解锁。</Alert.Description></Alert.Content></Alert> : null}
{mode === 'view' ? (
<Surface className="rc-detail-surface p-5" variant="secondary">
<SubjectViewContent view={activeView} subject={subject} editableRecord={editableRecord} onFieldUpdate={handleFieldUpdate} mode={mode} readonly={effectiveReadonly} isFieldDisabled={isDrawerFieldDisabled} onOpenReference={openReference} />
</Surface>
) : (
<SubjectViewContent view={activeView} subject={subject} editableRecord={editableRecord} onFieldUpdate={handleFieldUpdate} mode={mode} readonly={effectiveReadonly} isFieldDisabled={isDrawerFieldDisabled} onOpenReference={openReference} />
)}
<div>
{saveError ? (
<Alert className="mb-4" status="danger">
<Alert.Content>
<Alert.Description>{saveError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{isRecordLocked ? (
<Alert status="warning">
<Alert.Content>
<Alert.Description>当前记录已锁定,仅可查看。</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<SubjectViewContent
view={activeView}
subject={subject}
editableRecord={displayedRecord}
editSessionId={editSessionRef.current}
mode={isEditing ? 'edit' : 'view'}
onFieldChange={handleDraftFieldChange}
onFieldValidation={handleFieldValidation}
onPendingLocalMaterialChange={handlePendingLocalMaterialChange}
readonly={effectiveReadonly}
isFieldDisabled={isDrawerFieldDisabled}
minimal={minimalContent}
useAuditMaterialCenter={useAuditMaterialCenter}
/>
</div>
)}
</Modal.Body>
<Modal.Footer>
{mode === 'edit' ? (
<><Button isDisabled={saving} size="sm" variant="secondary" onPress={cancelEdit}>取消</Button><Button isPending={saving} size="sm" variant="primary" onPress={saveChanges}>{saving ? '保存中...' : '保存'}</Button></>
) : effectiveReadonly || referenceStack.length > 0 ? (
<Button size="sm" variant="secondary" onPress={closeDrawer}>{referenceStack.length > 0 ? '返回上一级' : '关闭'}</Button>
) : (
<Button size="sm" variant="primary" onPress={() => setMode('edit')}>编辑</Button>
)}
</Modal.Footer>
{!minimalContent && (isEditing || hasEditableFields) ? (
<Modal.Footer>
{isEditing ? (
<>
<Button size="sm" variant="secondary" isDisabled={isSaving} onPress={resetEditSession}>
取消
</Button>
<Button
isDisabled={!hasDirtyFields || hasValidationErrors}
isPending={isSaving}
size="sm"
onPress={handleSave}
>
保存
</Button>
</>
) : hasEditableFields ? (
<Button size="sm" onPress={handleStartEditing}>编辑</Button>
) : null}
</Modal.Footer>
) : null}
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
......
import AppShell from '@/components/AppShell';
import { Archive, CheckSquare, FileDone, FileText, Settings } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from './notifications/notificationService';
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
......@@ -25,6 +29,7 @@ export default function IntegrityPracticeLayout() {
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [switcherOpen, setSwitcherOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const pageTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || APPLICATION_DISPLAY_NAME,
[location.pathname],
......@@ -34,10 +39,32 @@ export default function IntegrityPracticeLayout() {
document.title = `${pageTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [pageTitle]);
useEffect(() => {
let cancelled = false;
const refreshUnreadCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) setUnreadNotificationCount(count);
} catch {
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={() => setSwitcherOpen(true)}
open={sidebarOpen}
......
......@@ -94,6 +94,7 @@ export default function SubjectWorkbench() {
() => views.find((view) => view.name === activeViewName) || views[0] || null,
[activeViewName, views],
);
const hasAccessibleView = views.length > 0;
const columns = useMemo(
() => buildSubjectColumns(activeView, subject),
[activeView, subject],
......@@ -274,6 +275,7 @@ export default function SubjectWorkbench() {
<Toast.Provider placement="top" />
<RecordWorkbench
accessDenied={!loading && !hasAccessibleView}
views={views.map((view) => ({ ...view }))}
activeViewName={activeViewName}
onViewChange={(viewName) => {
......@@ -307,17 +309,17 @@ export default function SubjectWorkbench() {
columns={columns}
records={records}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
rowActions={rowActions}
onSelectionChange={hasAccessibleView ? setSelectedIds : undefined}
toolbarActions={hasAccessibleView ? toolbarActions : EMPTY_ARRAY}
selectionActions={hasAccessibleView ? selectionActions : EMPTY_ARRAY}
rowActions={hasAccessibleView ? rowActions : EMPTY_ARRAY}
states={{
loading,
error,
loadingTitle: '正在加载业务数据',
loadingDescription: '正在读取对象结构、视图配置和记录数据。',
}}
detailPanel={{
detailPanel={hasAccessibleView ? {
getRecordName: (record) => record?.name,
onDirtyClose: () => setRefreshKey((value) => value + 1),
render: ({ open, recordName, initialMode, onClose, onDataChange }) => (
......@@ -329,9 +331,11 @@ export default function SubjectWorkbench() {
onClose={onClose}
onDataChange={onDataChange}
filterView={subjectConfig?.drawerConfig?.filterView || EMPTY_ARRAY}
authorizationView={activeView}
useAuditMaterialCenter
/>
),
}}
} : null}
pagination={
pagination
? {
......
import {
ArrowPathIcon,
Delete,
} from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import {
queryAuditMaterials,
uploadIntegrityPracticeFile,
} from '../integrityPracticeMaterialService';
import {
Alert,
Button,
Label,
Tabs,
Tooltip,
} from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
const MATERIAL_CENTER_TAB = 'material-center';
const LOCAL_UPLOAD_TAB = 'local-upload';
function toFileArray(value) {
if (Array.isArray(value)) return value.filter(Boolean);
return value ? [value] : [];
}
function getFileKey(file = {}) {
return file.url || [file.name, file.type, file.size].filter(Boolean).join('|');
}
function appendUniqueFiles(currentFiles, nextFiles) {
const filesByKey = new Map();
[...currentFiles, ...nextFiles].forEach((file) => {
const key = getFileKey(file);
if (key) filesByKey.set(key, file);
});
return [...filesByKey.values()];
}
function formatMaterialDescription(material) {
return [
material.fileAsset?.name,
material.archiveCategory,
material.archiveYear,
].filter(Boolean).join(' · ');
}
export default function IntegrityPracticeMaterialField({
editSessionId,
fieldPath,
multiple = false,
onChange,
onPendingLocalMaterialChange,
onValidationChange,
value,
}) {
const [activeTab, setActiveTab] = useState(MATERIAL_CENTER_TAB);
const [keyword, setKeyword] = useState('');
const [debouncedKeyword, setDebouncedKeyword] = useState('');
const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState('');
const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [localFileKeys, setLocalFileKeys] = useState(() => new Set());
const fileValues = useMemo(
() => (multiple ? toFileArray(value) : toFileArray(value).slice(0, 1)),
[multiple, value],
);
useEffect(() => {
setActiveTab(MATERIAL_CENTER_TAB);
setKeyword('');
setDebouncedKeyword('');
setSelectedMaterialId(null);
setMaterialsError('');
setUploadError('');
setLocalFileKeys(new Set());
}, [editSessionId]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setDebouncedKeyword(keyword);
}, 300);
return () => window.clearTimeout(timeoutId);
}, [keyword]);
useEffect(() => {
if (activeTab !== MATERIAL_CENTER_TAB) return undefined;
let cancelled = false;
setMaterialsLoading(true);
setMaterialsError('');
queryAuditMaterials(debouncedKeyword)
.then((nextMaterials) => {
if (!cancelled) setMaterials(nextMaterials);
})
.catch((error) => {
if (!cancelled) {
setMaterials([]);
setMaterialsError(error?.message || '审计材料加载失败,请稍后重试。');
}
})
.finally(() => {
if (!cancelled) setMaterialsLoading(false);
});
return () => {
cancelled = true;
};
}, [activeTab, debouncedKeyword, retryKey]);
const materialOptions = useMemo(
() => materials.map((material) => ({
...material,
id: material.id,
label: material.materialTitle,
textValue: `${material.materialTitle} ${material.fileAsset?.name || ''}`,
})),
[materials],
);
const selectedMaterial = useMemo(
() => materials.find((material) => material.id === selectedMaterialId),
[materials, selectedMaterialId],
);
const selectedValueText = multiple
? fileValues.length > 0
? `已选 ${fileValues.length} 个附件,可继续添加`
: '请选择审计材料'
: selectedMaterial?.materialTitle || fileValues[0]?.name || '请选择审计材料';
const handleMaterialSelection = (key) => {
const materialId = key ? String(key) : '';
const material = materials.find((item) => String(item.id) === materialId);
if (!material?.fileAsset?.url) return;
const fileAsset = {
name: material.fileAsset.name,
type: material.fileAsset.type || 'application/octet-stream',
size: Number(material.fileAsset.size || 0),
url: material.fileAsset.url,
};
setUploadError('');
if (multiple) {
onChange?.(appendUniqueFiles(fileValues, [fileAsset]));
setSelectedMaterialId(null);
return;
}
setSelectedMaterialId(material.id);
setLocalFileKeys(new Set());
onPendingLocalMaterialChange?.(
fieldPath,
{ type: 'replace', materials: [] },
editSessionId,
);
onChange?.(fileAsset);
};
const handleLocalUpload = async (files) => {
const selectedFiles = multiple ? toFileArray(files) : toFileArray(files).slice(0, 1);
if (selectedFiles.length === 0) return;
setUploading(true);
setUploadError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
const uploadedAssets = [];
const failedFiles = [];
for (const file of selectedFiles) {
try {
uploadedAssets.push(await uploadIntegrityPracticeFile(file));
} catch (error) {
failedFiles.push({ file, error });
}
}
if (uploadedAssets.length > 0) {
const pendingMaterials = uploadedAssets.map((fileAsset) => ({
clientId: getFileKey(fileAsset),
fileAsset,
materialRecordId: null,
}));
const uploadedKeys = pendingMaterials.map((item) => item.clientId);
setLocalFileKeys((currentKeys) => new Set(
multiple ? [...currentKeys, ...uploadedKeys] : uploadedKeys,
));
setSelectedMaterialId(null);
onPendingLocalMaterialChange?.(
fieldPath,
{
type: multiple ? 'add' : 'replace',
materials: pendingMaterials,
},
editSessionId,
);
onChange?.(multiple
? appendUniqueFiles(fileValues, uploadedAssets)
: uploadedAssets[uploadedAssets.length - 1]);
}
if (failedFiles.length > 0) {
const failedNames = failedFiles.map(({ file }) => file?.name).filter(Boolean).join('、');
setUploadError(
failedFiles.length === selectedFiles.length
? failedFiles[0].error?.message || '附件上传失败,请稍后重试。'
: `部分附件上传失败:${failedNames || `${failedFiles.length} 个文件`},可重新选择后再试。`,
);
}
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
};
const handleRemove = (file, fileIndex) => {
const fileKey = getFileKey(file);
setUploadError('');
setLocalFileKeys((currentKeys) => {
const nextKeys = new Set(currentKeys);
nextKeys.delete(fileKey);
return nextKeys;
});
onPendingLocalMaterialChange?.(
fieldPath,
{ type: 'remove', clientId: fileKey },
editSessionId,
);
if (multiple) {
onChange?.(fileValues.filter((_, index) => index !== fileIndex));
return;
}
setSelectedMaterialId(null);
onChange?.(null);
};
return (
<div className="space-y-4">
{fileValues.length > 0 ? (
<div className="space-y-2">
{fileValues.map((file, index) => {
const fileKey = getFileKey(file);
const fileLabel = file?.name || file?.url || `附件 ${index + 1}`;
return (
<div className="flex min-w-0 items-center justify-between gap-3" key={`${fileKey}-${index}`}>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground" title={fileLabel}>
{fileLabel}
</div>
<div className="mt-1 text-xs text-muted">
{localFileKeys.has(fileKey) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}
</div>
</div>
<Tooltip>
<Button
aria-label={`删除附件:${fileLabel}`}
isDisabled={uploading}
isIconOnly
size="sm"
variant="danger-soft"
onPress={() => handleRemove(file, index)}
>
<Delete size={16} />
</Button>
<Tooltip.Content>删除当前附件</Tooltip.Content>
</Tooltip>
</div>
);
})}
</div>
) : (
<span className="text-sm text-muted">未上传</span>
)}
<Tabs
aria-label="附件来源"
className="w-fit max-w-full min-w-0"
selectedKey={activeTab}
variant="secondary"
onSelectionChange={(key) => setActiveTab(String(key))}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List className="w-max min-w-max">
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={MATERIAL_CENTER_TAB}>
材料中心
<Tabs.Indicator />
</Tabs.Tab>
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" id={LOCAL_UPLOAD_TAB}>
本地上传
<Tabs.Indicator />
</Tabs.Tab>
</Tabs.List>
</Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<div className="space-y-3">
<XinyuanAutocomplete
aria-label="选择审计材料"
emptyText="没有找到可选择的审计材料"
errorText={materialsError}
inputValue={keyword}
isDisabled={uploading}
isLoading={materialsLoading}
options={materialOptions}
placeholder="请选择审计材料"
searchPlaceholder="搜索材料标题或文件名"
selectedKey={selectedMaterialId}
shouldFilter={false}
valueText={selectedValueText}
variant="secondary"
onInputChange={setKeyword}
onSelectionChange={handleMaterialSelection}
renderOption={(material) => (
<span className="min-w-0">
<Label className="block truncate" title={material.materialTitle}>
{material.materialTitle}
</Label>
<span className="mt-0.5 block truncate text-xs text-muted" title={formatMaterialDescription(material)}>
{formatMaterialDescription(material)}
</span>
</span>
)}
/>
{materialsError ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{materialsError}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />
重试
</Button>
</Alert>
) : null}
</div>
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}>
<div className="space-y-3">
<FileUploadField
description={multiple
? '可一次选择多个文件;上传后先保留为详情草稿,保存详情时逐个同步写入审计材料中心。'
: '上传后先保留为详情草稿,保存详情时同步写入审计材料中心。'}
isDisabled={uploading}
multiple={multiple}
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload}
/>
{uploadError ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content>
</Alert>
) : null}
</div>
</Tabs.Panel>
</Tabs>
</div>
);
}
......@@ -21,21 +21,23 @@
## 2. 列表与视图流程
1. 进入页面后并行调用 `GET /subject/:name` 和 `GET /subject/:name/view`。
2. 只展示当前用户有权访问的 `grid` 视图;配置包含 `filterView` 且非空时,只展示指定视图。
1. 进入页面后并行调用 `GET /subject/:name` 和 `GET /subject/:name/view`;打开详情后再按 View 名调用 `GET /subject/:subjectName/view/:viewName` 获取完整 Detail 配置。
2. 只展示当前用户有权访问的 `grid` 视图;授权只读取 `user.applications[]` 中 `name = integrity_practice_management` 的项目。系统管理员、系统账号和该应用“管理”角色可旁路,但不能旁路 `disableColumns`。配置包含 `filterView` 且非空时,只展示指定视图。
3. 切换视图后,根据视图的 `settings.restrictions`、`settings.filters.conditions`、列排序配置调用 `POST /record/query`。
4. `gridRecordConfig.pagination = false` 时单次最多读取 1000 条;其余页面使用服务端分页。
5. 关键词查找通过 `/record/query` 的 `keyword` 查询标题;清空筛选后回到第 1 页。
6. 列定义、列顺序、初始排序、固定列和字段显示格式由 Subject View 与 Subject 字段定义共同决定;HeroUI Pro DataGrid 仅在真实横向溢出时固定首列和操作列,并允许当前页原生列排序和列宽调整,不改变服务端分页口径。
`settings.shortTitle === true` 时只隐藏 title 展示列,标题仍参与查询、记录身份和保存;false 或未配置时正常展示。
7. DataGrid 使用原生 Checkbox 选择;选中后显示 HeroUI Pro ActionBar。行操作 1-3 项直接展示,4 项以上展示前两项与“更多”菜单,删除等危险动作始终排在最后。
8. 点击列表操作列的预览图标打开 HeroUI Modal 并进入只读查看态;首个业务列只展示记录文本,不再承担预览交互。点击弹窗统一的“编辑”按钮或列表操作列铅笔图标进入整体编辑态,保存后返回查看态。字段权限、锁定规则和 Subject 字段类型保持不变。
9. 编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为 `_id` 或 `_id[]`,不改变保存时机和 `metadata` 协议。
9. 编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
9. 当前 Detail 有有效 `settings.auth` 或 `settings.disableColumns` 时,字段权限整套使用 Detail;两项都未配置或没有实际 Detail 时,整体回退打开详情的当前 Grid。两套权限不合并、不逐字段回退;`disableColumns` 对管理员也始终只读,普通用户在选定权限来源中缺少字段 `auth` 时只读。切换 Detail 后立即重算字段状态和编辑入口,编辑入口只检查当前 Detail 展示字段。Grid `viewAuth` 只控制 Tab 可见性,不隐藏详情字段。
10. 编辑态远程引用字段通过 HeroUI 弹层搜索选择。多值字段在触发器中显示已选数量,可在弹层内连续选择、取消或清空;单值字段选中后关闭弹层。候选标题过长时单行省略但保留全文提示,写入值仍严格为 `_id` 或 `_id[]`,不改变保存时机和 `metadata` 协议。
11. 编辑态的单选和多选属性使用 HeroUI Select/Autocomplete,日期使用 DatePicker,附件使用 HeroUI Pro DropZone;选择后立即更新当前弹窗草稿,最终仍由弹窗底部统一“保存”提交。
异常处理:
- Subject、视图或记录加载失败时,使用 HeroUI Alert/EmptyState 显示错误或空态,不使用猜测字段兜底。
- 后端没有可用 `grid` 视图时,使用“全部”临时视图并按 Subject 字段生成列。
- 没有可访问 `grid` 视图时不创建兜底 View、不查询记录,并隐藏新增、批量和行操作;只有一个可访问 View 时隐藏 Tabs 轨道。
- 配置动作执行期间禁用重复点击;成功后使用顶部 Toast,失败时保留当前列表并显示服务端错误。
## 3. 记录选择与动作规则
......@@ -127,3 +129,17 @@
- `POST /record/batch/remove`
所有业务字段写入 `metadata`;引用字段、人员字段和归档关联字段只保存 `_id` 或 `_id[]`。日期由产生该状态的动作统一写入 ISO 8601 或历史协议明确要求的年份字符串。
## 9. 通知中心
- Navbar Bell 进入 `/notifications`,Sidebar 不增加通知项;未读数来自 `GET /notice/customer/count`,通知变更事件和 60 秒轮询刷新。
- 通知页按消息/待办、全部/未读、300ms 关键词、每页 10 条查询 `POST /notice/customer/query`;未读状态传服务端 `status = unread`。
- 单条和全部已读调用 `PUT /notice/customer/read`;待办动态 action 保留服务端 action/method 名并只提交 `{notice_id}`。
- 正文只按纯文本渲染。仅去除本应用 `/integrity_practice_management` base 后站内跳转;其它应用和外部链接新窗口打开。
## 10. 文件与审计材料中心
- 所有运行时可编辑 `file` 字段按 `multiple` 提供“材料中心 / 本地上传”。材料中心查询 `am_material` 最近 20 条并远程搜索材料标题、文件名。
- 选择材料只保存 `{name,type,size,url}` 快照;单值替换,多值追加并按 URL 去重。清空附件不删除材料主档。
- 本地文件先调用 `/upload` 写入详情草稿;取消编辑不建档。点击保存后,对仍保留的本地文件逐个创建 `am_material`,`source_app = integrity_practice_management`,全部成功后才更新业务 Record。
- 同一编辑会话记录已建档文件;材料部分成功或业务更新失败后重试只处理未建档项,避免重复材料。
import dayjs from 'dayjs';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
export const INTEGRITY_PRACTICE_SOURCE_APP = 'integrity_practice_management';
const MATERIAL_PAGE_SIZE = 20;
function extractRecords(response) {
const candidates = [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
response?.result?.records,
];
return candidates.find((candidate) => Array.isArray(candidate)) || [];
}
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 || '',
sourceApp: metadata.source_app || '',
uploadedAt: metadata.uploaded_at || record.created_at || '',
};
}
export async function queryAuditMaterials(keyword = '') {
const normalizedKeyword = String(keyword || '').trim();
const escapedKeyword = escapeRegExp(normalizedKeyword);
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 uploadIntegrityPracticeFile(file) {
if (!file) {
throw new Error('请选择要上传的本地文件。');
}
const uploadResult = await uploadFile(file);
return normalizeFileAsset(uploadResult, file);
}
export async function createIntegrityPracticeMaterial({ 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: INTEGRITY_PRACTICE_SOURCE_APP,
uploader: uploaderId,
uploaded_at: dayjs().toISOString(),
},
});
const createdRecord = response?.data || response?.result || response;
return {
id: createdRecord?._id || createdRecord?.id || createdRecord?.name || true,
record: createdRecord,
};
}
......@@ -81,10 +81,7 @@ export async function loadSubjectResources(subjectName, config = {}) {
return {
subject,
views:
availableViews.length > 0
? availableViews
: [{ name: 'all', title: '全部', type: 'grid', settings: {} }],
views: availableViews,
};
}
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
Chip,
SearchField,
Spinner,
Pagination,
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 NotificationPagination({ isDisabled, page, pageSize, total, onPageChange }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageWindowStart = Math.max(0, Math.min(page - 2, totalPages - 5));
const pageWindowEnd = total ? Math.min(totalPages, pageWindowStart + 5) : pageWindowStart;
return (
<Pagination aria-label="通知分页" size="sm">
<Pagination.Summary>共 {total} 条通知,第 {page + 1} / {totalPages} 页</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous isDisabled={isDisabled || page <= 0} onPress={() => onPageChange(page - 1)}>
<Pagination.PreviousIcon /><span>上一页</span>
</Pagination.Previous>
</Pagination.Item>
{Array.from({ length: pageWindowEnd - pageWindowStart }, (_, index) => {
const pageIndex = pageWindowStart + index;
return (
<Pagination.Item key={pageIndex}>
<Pagination.Link isActive={pageIndex === page} isDisabled={isDisabled} onPress={() => onPageChange(pageIndex)}>
{pageIndex + 1}
</Pagination.Link>
</Pagination.Item>
);
})}
<Pagination.Item>
<Pagination.Next isDisabled={isDisabled || page + 1 >= totalPages} onPress={() => onPageChange(page + 1)}>
<span>下一页</span><Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
);
}
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 ? (
<NotificationPagination
isDisabled={loading}
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 = 'integrity-practice-management: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<>"']+|\/integrity_practice_management\/[^\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('/integrity_practice_management/')) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch (error) {
return null;
}
}
const appPrefix = '/integrity_practice_management';
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));
}
}
import dayjs from 'dayjs';
import { AUTHORIZATION_APPLICATION_NAME } from '@/config/application';
export function normalizeText(value) {
return String(value ?? '').trim();
......@@ -57,62 +58,74 @@ 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) => value && typeof value === 'object'
? [value._id, value.name, value.display_name, value.displayName, value.username]
: 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));
}
export function hasAuthAccess(authRule, user = getCurrentUserFromStorage()) {
if (!authRule) {
return true;
}
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;
return intersects(collectApplicationAuthorization(user, applicationName).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 === 'role') {
return intersects(collectUserRoles(user), authRule.roles);
if (authRule?.type === 'group') {
return intersects(groups, authRule.groups);
}
if (authRule.type === 'permission') {
return intersects(collectUserPermissions(user), authRule.permissions);
if (authRule?.type === 'role') {
return intersects(roles, authRule.roles);
}
if (authRule.type === 'username') {
return intersects([user.username, user.display_name, user._id], authRule.usernames);
if (authRule?.type === 'permission') {
return intersects(permissions, authRule.permissions);
}
return intersects(collectUserRoles(user), ['管理']);
return false;
}
export function getFieldValue(record, fieldPath) {
......@@ -262,12 +275,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 fieldDef = getFieldDefinition(column.field, subject);
const label = getFieldLabel(column.field, fieldDef, column.label);
......@@ -285,12 +304,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}`;
......@@ -363,18 +384,44 @@ 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,
) {
const viewAuth = view?.settings?.viewAuth;
const rule = ['group', 'role', 'permission'].includes(viewAuth?.type) ? viewAuth : '管理';
return hasAuthAccess(rule, user, applicationName);
}
export function isFieldDisabledByView(fieldPath, view, user = getCurrentUserFromStorage()) {
export function hasFieldAuthorizationConfiguration(view) {
const settings = view?.settings;
return (
toArray(settings?.auth).some((rule) => normalizeText(rule?.field)) ||
toArray(settings?.disableColumns).some((column) => normalizeText(column?.field))
);
}
export function isFieldDisabledByView(
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;
const rule = ['group', 'role', 'permission'].includes(fieldAuthRule?.type)
? fieldAuthRule
: '管理';
return !hasAuthAccess(rule, 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