Commit 2078e013 authored by Andy-bubu's avatar Andy-bubu

feat: sync risk case Xinyuan capabilities

parent b4b5a6e7
......@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
login: '/login',
resourceStat: '/resource/stat',
resourceBooking: '/resource/booking',
notifications: '/notifications',
} as const;
export default defineConfig({
......@@ -47,6 +48,10 @@ export default defineConfig({
path: ROUTE_PATHS.resourceBooking,
component: './project_manager_xy/resource-booking/index',
},
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
],
},
],
......
......@@ -8,7 +8,7 @@
- UI 原语与 compound component 在页面或业务中立共享组合中直接导入 HeroUI/HeroUI Pro。
- 共享组合只可封装重复布局、可访问性与当前 HeroUI value/selection/event 模型,不得复刻 Catalyst props、DOM event 或旧 CSS hook。
- DataGrid 组合映射列定义并转发原生 selection/fixed-column/ActionBar/Pagination;分页容量继续保持 20/50/100,并使用 HeroUI Autocomplete compound API。
- DataGrid 组合映射列定义并转发原生 selection/fixed-column/ActionBar/Pagination;案例列表分页容量固定为 20,不展示容量选择器。
- 文件上传采用 HeroUI Pro DropZone 的 `File[]` 协议;页面边界继续只取既有单文件值。
- 应用切换直接组合 HeroUI Modal/Card/Surface/Avatar 与 HeroUI Pro EmptyState;分组只读取现有 `SiteContext` 的 `/info -> site.group`,应用仍合并 `/application/info` 与当前用户权限数据,不引入硬编码分组或应用。
......@@ -25,3 +25,11 @@
## 业务边界
服务层继续负责 `risk_case_library` 的 `/record` 协议,所有业务字段仍写入 `metadata`。本次迁移不得改变路由、鉴权、权限、验证、分页、保存回调、请求时机、错误处理或业务状态流。
## 四项功能数据流
- `riskCaseViewService.js` 读取 `risk_case_library` Subject 及完整 Grid/Detail View。页面只消费 columns、visabled 与 shortTitle;不读取或执行 `viewAuth`、`auth`、`disableColumns`、角色、用户组或权限旁路。
- View 资源不可用时,案例查询照常执行,并回退到原案例字段集合;不会因为配置接口失败而只剩标题列,也不会改变 `POST /record/query` 的筛选和 `-updated_at -created_at` 排序。
- `notifications/notificationService.js` 封装平台 `/notice/customer/query`、`/count`、`/read` 与动态 action;站内目标剥离 `/risk_case_library` base,外部目标新窗口打开,通知 HTML 只作为文本展示。
- `RiskCaseMaterialField.jsx` 按运行时 `multiple` 决定单值替换或多值追加。材料中心只写文件快照;本地 `/upload` 先进入详情草稿,保存详情时才创建 `am_material`,再更新业务记录。
- 本地材料草稿按编辑会话隔离;取消不建档。创建成功标记保留在草稿中,后续业务保存失败重试时不重复创建材料主档。
......@@ -18,6 +18,15 @@
- [x] 弹窗:新建、查看/编辑、批量修改、导入、删除确认(代码结构)
- [x] 清除 Catalyst/Headless/ECharts/原生可见控件与页面私有视觉 CSS
## 四项功能
- [x] Grid shortTitle 只隐藏 title 展示列,详情入口保留在操作列
- [x] Detail shortTitle 随激活 View 隐藏记录标题和 title 字段
- [x] `/notifications`、Navbar Badge、平台通知交互与 base-aware 跳转
- [x] 可编辑 file 字段的材料中心/本地上传、草稿、保存建档与失败重试去重
- [x] View/字段权限逻辑不在本次范围,活动实现扫描为零
- [x] Subject/View 加载失败时回退完整默认案例字段,业务查询不阻断
## 浏览器验收
- [x] Login 1440x900 light/dark
......@@ -26,7 +35,14 @@
- [x] Login 390x844 light/dark
- [x] 应用切换 1440x900、1280x720、768x1024、390x844 light/dark:尺寸、两列/单列、动态分组、全部应用回退、唯一当前态、Body 滚动、Escape 与焦点回归
- [ ] 打开 Sidebar、用户退出菜单、应用切换真实动态分组、Dropdown/ListBox/DatePicker
- [ ] 验证 Tabs、筛选、Checkbox、DataGrid、Pagination、ActionBar、Modal、KPI、图表 Tooltip
- [ ] 验证 KPI 动态状态与图表 Tooltip
- [x] 目标与问题整改在 1440x900、1280x720、768x1024、390x844 完成浅/深色成对截图,页面无 viewport 级横向溢出
- [x] 真实验证 Tabs、三项折叠筛选、Checkbox、DataGrid、Pagination、ActionBar、详情查看/编辑/取消和移动端通知空态
- [x] 目标与基准最终控制台 0 error / 0 warning
- [ ] 验证 shortTitle true/false、单/多值附件、材料选择、本地上传、取消、失败与重试
- [ ] 验证通知未读 0/1/99/100、单条/全部已读、动态操作与站内外跳转
未勾选原因:真实 View 接口返回空数组,Subject 没有 file 字段,通知接口返回 0 条;未伪造后台配置、通知或业务数据。
- [x] Login 控制台 0 runtime/slot/provider/accessibility 错误或警告
## 最终门槛
......
......@@ -20,6 +20,17 @@
- 指标必须使用 HeroUI Pro KPI compound API;图表必须使用 Pro PieChart、BarChart、ComposedChart 与 ChartTooltipContent。
- 图表业务序列只使用 `--rc-chart-*` Token,不使用 UI 状态色或页面硬编码颜色。
## 跨应用能力
风险案例库属于“警示案例库”,按完整功能应用范围实施本次四项能力:
- Grid View 的 `settings.shortTitle === true` 只隐藏 `title` 展示列;原始标题数据、查询、排序、筛选、详情和保存协议不变,详情入口保留在右侧操作列。
- 当前 Detail View 的 `settings.shortTitle === true` 同时隐藏记录标题和 `title` 字段;Modal 功能标题与可访问名称始终存在。
- 通知中心使用独立 `/notifications` 路由和平台原通知接口,Navbar 以 HeroUI `Badge.Anchor + Badge + Button + Tooltip` 提供入口。
- 运行时 Subject 中可编辑的 `file` 字段使用 HeroUI Pro DropZone 本地上传,或通过 HeroUI Autocomplete 选择 `am_material`;业务记录只保存 `{name,type,size,url}` 快照。
本次范围不包含 View 权限、字段权限、角色旁路或用户组判断。View 配置只决定列、字段顺序、条件可见性和 `shortTitle` 展示。
## 禁止项
- Catalyst、Headless UI、旧 UI 包装层、可见原生 select/date/file 控件。
......
# 鑫元 UI 审计
审计日期:2026-08-26。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。整体实现基准为“问题整改”,应用切换专项基准为 `model_analysis` 当前 HeroUI 实现。
审计日期:2026-08-27。目标仓库为 Umi 4 / React 19,部署基路径为 `/risk_case_library/`。整体实现基准为“问题整改”,应用切换专项基准为 `model_analysis` 当前 HeroUI 实现。
## 安装与运行基线
......@@ -31,8 +31,30 @@
| 旧组件 | Catalyst、Headless、ECharts、旧 Select/Date/Table 兼容层已移除 | 活动源码扫描为零 |
| 样式 | `theme.css` 匹配 Skill;`tokens.css`、`shell.css`、`tailwind.css`、`global.less` 匹配问题整改 | `portable.css` 与页面私有视觉 CSS 已移除 |
## 四项功能审计
| 功能 | 目标实现 | 业务保护 |
| --- | --- | --- |
| Grid shortTitle | 完整 Grid View 的 `settings.shortTitle` 仅过滤 `title` 展示列;详情入口固定在操作列 | 不删除标题数据,不修改查询、排序、筛选、详情或保存 payload |
| Detail shortTitle | 随当前 Detail View 即时隐藏顶部记录标题和 `title` 字段 | Modal 标题和 aria-label 保留;Detail 切换不触发业务请求 |
| 消息通知 | 新增 `/notifications`;消息/待办、全部/未读、搜索、分页、单条/全部已读、动态 actions、站内外跳转;Navbar Bell 显示 1–99/99+ | 复用平台原通知接口;不执行通知 HTML;Sidebar 无通知入口 |
| 文件与材料中心 | 可编辑 file 字段按 multiple 支持材料中心/本地上传;最近 20 条与远程搜索;保存时建档 | 快照固定 `{name,type,size,url}`;`source_app=risk_case_library`;取消不建档,失败重试去重 |
View 配置审计确认本次没有实现权限功能:活动代码不读取 `viewAuth`、`settings.auth`、`disableColumns`、用户角色、用户组或 permissions。
## 风险与保护
- 不改变 API URL、请求参数、响应映射、路由、权限、校验、请求时机、保存回调、登录退出或业务状态流。
- 除新增 `/notifications` 和材料中心明确请求外,不改变原 API URL、请求参数、响应映射、路由、校验、请求时机、保存回调、登录退出或业务状态流。
- 不在源码或浏览器中加入测试记录、伪造 token 或统计数字。
- 应用切换已使用现有真实登录会话完成浅/深色四视口验收;未注入 token、应用、分组或业务数据。其他模块的既有未验收项仍按迁移清单保留,不能据此标记全量应用迁移完成。
## 2026-08-27 浏览器验收
- 使用账号 Andy 经真实验证码登录目标应用和“问题整改”,未注入 token、通知、View、附件或测试记录。
- 在 1440x900、1280x720、768x1024、390x844 分别采集两应用浅色和深色截图。各视口目标页面 `body.scrollWidth === innerWidth`,宽表滚动限制在 DataGrid 内;桌面 Sidebar/Navbar 为 240px/64px,移动端 Main 与 Navbar 均占满视口。
- 对照确认两应用在相同视口下的 Sidebar、Navbar、Trigger、Bell、主题按钮、页面内容边界、Tabs、筛选 Surface 与工具栏层级一致。
- 目标真实验证:筛选默认三项与 Disclosure、卡片/表格 Tabs、DataGrid 固定操作列、Checkbox、ActionBar、固定 20 条 Pagination、详情 4xl Modal、查看态单一 secondary Surface、编辑 dirty、取消恢复且无 PUT、移动端通知空态、移动端 Sidebar、应用切换分组/计数/当前态。
- 目标与基准最终控制台均为 0 error、0 warning(忽略开发模式 React DevTools info)。
- 当前真实接口 `GET /subject/risk_case_library/view` 返回空数组,因此没有 shortTitle true/false 配置可触发;代码路径与配置失败完整字段回退已审计,但未把 Mock 当作浏览器通过。
- 当前 Subject 的 11 个字段均非 `file`,无法在真实页面触发单值/多值、材料选择、本地上传、取消、失败与重试。
- 当前通知列表和未读数均为 0,可验证空态与 Badge 隐藏;1/99/100、单条/全部已读、动态 actions 和站内外目标没有真实通知数据可操作。
import BankOutlined from '@ant-design/icons/BankOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
......@@ -45,6 +46,10 @@ 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({ size, style, ...props }) {
return (
......@@ -54,6 +59,7 @@ function withSize(Icon) {
}
export const Bank = withSize(BankOutlined);
export const Bell = withSize(BellOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
......
......@@ -9,6 +9,7 @@ export default function FileUploadField({
description = '将文件拖到此处,或从本地选择。',
isDisabled = false,
fileName = '',
multiple = false,
onFilesChange,
triggerLabel = '选择文件',
}) {
......@@ -29,9 +30,9 @@ export default function FileUploadField({
if (fileItems.length === 0) return;
const files = await Promise.all(fileItems.map((item) => item.getFile()));
emitFiles(files.slice(0, 1));
emitFiles(multiple ? files : files.slice(0, 1));
},
[emitFiles],
[emitFiles, multiple],
);
return (
......@@ -44,6 +45,7 @@ export default function FileUploadField({
<DropZone.Input
accept={accept}
disabled={isDisabled}
multiple={multiple}
onSelect={emitFiles}
/>
<DropZone.Icon>
......
import {
ChevronDown,
Check,
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]);
}
const toSelectionKey = (value) => String(value ?? '');
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 = '请选择',
renderOption,
searchPlaceholder = '输入关键词搜索',
selectedKey,
selectedKeys,
selectionMode = 'single',
shouldFilter = true,
valueText,
variant = 'secondary',
}) {
const selectedKeySet = getSelectedKeySet(selectionMode, selectedKey, selectedKeys);
const selectedKeyStringSet = new Set(
Array.from(selectedKeySet).map(toSelectionKey),
);
const selectedOptions = options.filter((option) =>
selectedKeyStringSet.has(toSelectionKey(option.id)));
const resolvedValue = valueText ?? (selectedOptions.length > 0
? selectedOptions.map((option) => option.label).join('、')
: placeholder);
const rootSelectionProps = selectionMode === 'multiple'
? { selectedKeys }
: { selectedKey };
const listBoxSelectionProps = selectionMode === 'multiple'
? { selectionMode, selectedKeys }
: {};
const visibleOptions = isLoading || errorText ? [] : options;
const handleMultipleOptionPress = (option) => {
if (selectionMode !== 'multiple' || !onSelectionChange || option?.isDisabled) return;
const optionKey = toSelectionKey(option.id);
if (!optionKey) return;
const nextKeys = new Set(Array.from(selectedKeyStringSet).filter(Boolean));
if (nextKeys.has(optionKey)) nextKeys.delete(optionKey);
else nextKeys.add(optionKey);
onSelectionChange(nextKeys);
};
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={resolvedValue}>
{resolvedValue}
</span>
</Autocomplete.Value>
<Autocomplete.Indicator>
<ChevronDown size={16} style={FIELD_SUFFIX_ICON_STYLE} />
</Autocomplete.Indicator>
</Autocomplete.Trigger>
<Autocomplete.Popover placement="bottom start">
<Autocomplete.Filter
filter={shouldFilter ? filter || defaultFilter : () => true}
inputValue={inputValue}
onInputChange={onInputChange}
>
<SearchField aria-label={searchPlaceholder} fullWidth variant="secondary">
<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}
{...listBoxSelectionProps}
renderEmptyState={() => (
<div className="grid min-h-20 place-items-center px-3 py-4">
{isLoading ? <Spinner size="sm" /> : errorText || emptyText}
</div>
)}
>
{(option) => {
const isSelected = selectedKeyStringSet.has(toSelectionKey(option.id));
return (
<ListBox.Item
id={option.id}
isDisabled={option.isDisabled}
textValue={option.textValue || option.label}
{...(selectionMode === 'multiple'
? { onPress: () => handleMultipleOptionPress(option) }
: {})}
>
{renderOption ? renderOption(option) : <Label>{option.label}</Label>}
{selectionMode === 'multiple' ? (
<span
aria-hidden="true"
className="ml-auto inline-flex w-5 justify-center text-foreground"
>
{isSelected ? <Check size={14} /> : null}
</span>
) : (
<ListBox.ItemIndicator />
)}
</ListBox.Item>
);
}}
</ListBox>
</Autocomplete.Filter>
</Autocomplete.Popover>
</Autocomplete>
);
}
import BankOutlined from '@ant-design/icons/BankOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
......@@ -54,6 +55,7 @@ function withSize(Icon) {
}
export const Bank = withSize(BankOutlined);
export const Bell = withSize(BellOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
......
......@@ -2,15 +2,19 @@ import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_DISPLAY_NAME } from '@/constants/app';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/pages/project_manager_xy/notifications/notificationService';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Avatar, Button, Dropdown, Label, Tooltip } from '@heroui/react';
import { useContext, useLayoutEffect, useMemo, useState } from 'react';
import { Avatar, Badge, Button, Dropdown, Label, Toast, Tooltip } from '@heroui/react';
import { useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { Heading } from 'react-aria-components/Dialog';
import { history, Outlet, useLocation } from 'umi';
import { AppGrid, Archive, BarChart, LogOut, Moon, Sun } from './AppIcons';
import { AppGrid, Archive, BarChart, Bell, LogOut, Moon, Sun } from './AppIcons';
const APP_NAME = APP_DISPLAY_NAME;
const THEME_STORAGE_KEY = 'xinyuan_color_theme';
......@@ -38,6 +42,7 @@ function getInitialTheme() {
}
function getRouteTitle(pathname) {
if (pathname === '/notifications') return '通知消息';
for (const group of menuGroups) {
const activeItem = group.items.find((item) => item.path === pathname);
if (activeItem) return activeItem.name;
......@@ -216,6 +221,8 @@ function SidebarContents({ onOpenApplicationSwitcher, pathname, userInfo }) {
}
function DashboardNavbar({
notificationCount,
onOpenNotifications,
onThemeChange,
theme,
title,
......@@ -223,6 +230,7 @@ function DashboardNavbar({
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const unreadCount = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -234,6 +242,26 @@ function DashboardNavbar({
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label="通知消息"
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={onOpenNotifications}
>
<Bell size={16} />
</Button>
{unreadCount > 0 ? (
<Badge aria-label={`${unreadCount}条未读通知`} color="danger" size="sm">
{unreadCount > 99 ? '99+' : unreadCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
<Tooltip delay={0}>
<Button
isIconOnly
......@@ -259,6 +287,7 @@ export default function ProjectManagerLayout() {
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [theme, setTheme] = useState(getInitialTheme);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
......@@ -280,6 +309,26 @@ export default function ProjectManagerLayout() {
document.title = APP_NAME;
}, []);
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]);
const routeTitle = useMemo(
() => getRouteTitle(location.pathname),
[location.pathname],
......@@ -300,6 +349,8 @@ export default function ProjectManagerLayout() {
/>
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={unreadNotificationCount}
onOpenNotifications={() => history.push('/notifications')}
theme={theme}
title={routeTitle}
onThemeChange={setTheme}
......@@ -318,6 +369,7 @@ export default function ProjectManagerLayout() {
userInfo={userInfo}
siteInfo={siteInfo}
/>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { ArrowPathIcon, Delete } from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, Button, Label, Tabs, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
import { queryRiskCaseMaterials, uploadRiskCaseFile } from './riskCaseMaterialService';
const MATERIAL_CENTER_TAB = 'material-center';
const LOCAL_UPLOAD_TAB = 'local-upload';
function toFiles(value) {
return (Array.isArray(value) ? value : value ? [value] : []).filter(Boolean);
}
function fileKey(file = {}) {
return file.url || [file.name, file.type, file.size].filter(Boolean).join('|');
}
function appendUnique(current, next) {
return [...new Map([...current, ...next].map((file) => [fileKey(file), file])).values()];
}
export default function RiskCaseMaterialField({
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 [selectedKey, setSelectedKey] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false);
const [localKeys, setLocalKeys] = useState(() => new Set());
const files = useMemo(() => multiple ? toFiles(value) : toFiles(value).slice(0, 1), [multiple, value]);
useEffect(() => {
setActiveTab(MATERIAL_CENTER_TAB);
setKeyword('');
setSelectedKey(null);
setError('');
setLocalKeys(new Set());
}, [editSessionId]);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedKeyword(keyword), 300);
return () => window.clearTimeout(timer);
}, [keyword]);
useEffect(() => {
if (activeTab !== MATERIAL_CENTER_TAB) return undefined;
let cancelled = false;
setLoading(true);
setError('');
queryRiskCaseMaterials(debouncedKeyword)
.then((items) => { if (!cancelled) setMaterials(items); })
.catch((requestError) => { if (!cancelled) { setMaterials([]); setError(requestError?.message || '审计材料加载失败。'); } })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [activeTab, debouncedKeyword, retryKey]);
const options = materials.map((material) => ({
...material,
label: material.materialTitle,
textValue: `${material.materialTitle} ${material.fileAsset?.name || ''}`,
}));
function handleMaterialSelection(key) {
const material = materials.find((item) => String(item.id) === String(key || ''));
if (!material?.fileAsset?.url) return;
const snapshot = {
name: material.fileAsset.name,
type: material.fileAsset.type || 'application/octet-stream',
size: Number(material.fileAsset.size || 0),
url: material.fileAsset.url,
};
setSelectedKey(multiple ? null : material.id);
if (!multiple) {
setLocalKeys(new Set());
onPendingLocalMaterialChange?.(fieldPath, { type: 'replace', materials: [] }, editSessionId);
}
onChange?.(multiple ? appendUnique(files, [snapshot]) : snapshot);
}
async function handleLocalUpload(selectedFiles) {
const nextFiles = multiple ? toFiles(selectedFiles) : toFiles(selectedFiles).slice(0, 1);
if (nextFiles.length === 0) return;
setUploading(true);
setError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
const uploaded = [];
const failed = [];
for (const file of nextFiles) {
try { uploaded.push(await uploadRiskCaseFile(file)); }
catch (uploadError) { failed.push({ file, uploadError }); }
}
if (uploaded.length > 0) {
const pending = uploaded.map((fileAsset) => ({ clientId: fileKey(fileAsset), fileAsset, materialRecordId: null }));
setLocalKeys((current) => new Set(multiple ? [...current, ...pending.map((item) => item.clientId)] : pending.map((item) => item.clientId)));
setSelectedKey(null);
onPendingLocalMaterialChange?.(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending }, editSessionId);
onChange?.(multiple ? appendUnique(files, uploaded) : uploaded[uploaded.length - 1]);
}
if (failed.length > 0) setError(failed.length === nextFiles.length ? failed[0].uploadError?.message || '附件上传失败。' : `部分附件上传失败:${failed.map((item) => item.file?.name).filter(Boolean).join('、')}`);
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
}
function removeFile(file, index) {
const key = fileKey(file);
setLocalKeys((current) => { const next = new Set(current); next.delete(key); return next; });
onPendingLocalMaterialChange?.(fieldPath, { type: 'remove', clientId: key }, editSessionId);
onChange?.(multiple ? files.filter((_, fileIndex) => fileIndex !== index) : null);
}
return <div className="space-y-4">
{files.length > 0 ? <div className="space-y-2">{files.map((file, index) => <div className="flex min-w-0 items-center justify-between gap-3" key={`${fileKey(file)}-${index}`}>
<div className="min-w-0"><div className="truncate text-sm font-medium" title={file.name || file.url}>{file.name || file.url}</div><div className="mt-1 text-xs text-muted">{localKeys.has(fileKey(file)) ? '本地文件,保存后同步至审计材料中心' : '已选择附件'}</div></div>
<Tooltip><Button isIconOnly aria-label={`删除附件:${file.name || index + 1}`} isDisabled={uploading} size="sm" variant="danger-soft" onPress={() => removeFile(file, index)}><Delete size={16} /></Button><Tooltip.Content>删除当前附件</Tooltip.Content></Tooltip>
</div>)}</div> : <span className="text-sm text-muted">未上传</span>}
<Tabs selectedKey={activeTab} variant="secondary" onSelectionChange={(key) => setActiveTab(String(key))}>
<Tabs.ListContainer><Tabs.List aria-label="附件来源"><Tabs.Tab id={MATERIAL_CENTER_TAB}>材料中心<Tabs.Indicator /></Tabs.Tab><Tabs.Tab id={LOCAL_UPLOAD_TAB}>本地上传<Tabs.Indicator /></Tabs.Tab></Tabs.List></Tabs.ListContainer>
<Tabs.Panel className="pt-3" id={MATERIAL_CENTER_TAB}>
<XinyuanAutocomplete aria-label="选择审计材料" emptyText="没有找到可选择的审计材料" errorText={error} inputValue={keyword} isDisabled={uploading} isLoading={loading} options={options} placeholder={multiple && files.length ? `已选 ${files.length} 个附件,可继续添加` : '请选择审计材料'} searchPlaceholder="搜索材料标题或文件名" selectedKey={selectedKey} shouldFilter={false} onInputChange={setKeyword} onSelectionChange={handleMaterialSelection} renderOption={(material) => <span className="min-w-0"><Label className="block truncate">{material.materialTitle}</Label><span className="block truncate text-xs text-muted">{[material.fileAsset?.name, material.archiveCategory, material.archiveYear].filter(Boolean).join(' · ')}</span></span>} />
{error ? <Alert className="mt-3" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="danger-soft" onPress={() => setRetryKey((value) => value + 1)}><ArrowPathIcon size={14} />重试</Button></Alert> : null}
</Tabs.Panel>
<Tabs.Panel className="pt-3" id={LOCAL_UPLOAD_TAB}><FileUploadField isDisabled={uploading} multiple={multiple} triggerLabel={uploading ? '上传中...' : '选择本地文件'} onFilesChange={handleLocalUpload} /></Tabs.Panel>
</Tabs>
</div>;
}
......@@ -13,7 +13,7 @@
当前阶段的核心原则是:
- 每条案例对应一条独立 `Record`
- 案例详情以文本为主,不额外拆分附件对象
- 案例详情以文本为主;若运行时 Subject/View 配置出现可编辑 `file` 字段,则按统一文件快照协议接入审计材料中心
- 页面优先保证“看起来直观、方便查”
---
......@@ -341,5 +341,36 @@
1. `risk_case_library`
当前不拆分独立关键字表、案例性质表或附件表。
当前不拆分独立关键字表、案例性质表或案例专属附件表。运行时 `file` 字段可引用共享 `am_material`,但风险案例记录只保存文件快照,不保存材料 ID。
案例性质先通过文本字段实现,关键字通过多值文本数组实现,后续如有需要再演进。
---
## 11. Subject View 展示规则
1. 页面读取 `risk_case_library` 的 Grid 与 Detail View 完整配置。
2. Grid `settings.shortTitle === true` 时只隐藏根字段 `title` 的展示列;标题数据仍参与原有业务协议,查看入口统一保留在右侧操作列。
3. 当前 Detail View `settings.shortTitle === true` 时,隐藏弹窗顶部记录标题和字段列表中的根 `title` 字段;Modal 功能标题与可访问名称保持不变。
4. 切换 Grid View 只切换列展示,切换 Detail View 只切换字段展示;两者都不修改案例查询、排序、筛选或保存协议。
5. Subject/View 加载失败时继续查询案例,并回退展示原案例字段集合。
6. 本模块不根据 View 配置执行可见权限或字段编辑权限,不读取用户角色、用户组或其他应用授权。
## 12. 消息通知流程
1. Navbar 主题按钮左侧的 Bell 打开独立 `/notifications` 页面;Sidebar 不提供通知入口。
2. 页面通过平台通知接口查询消息/待办,支持全部/未读、300ms 搜索与固定 10 条分页。
3. 单条已读调用平台已读接口;全部已读调用同一接口的空 name 协议;操作成功后刷新列表和 Navbar 数量。
4. 待办 actions 使用通知原配置调用动态 action 接口,不构造自定义动作。
5. 关联地址带 `/risk_case_library` 前缀时去除自身 base 后站内跳转;外部地址新窗口打开。
6. 通知内容始终按纯文本渲染,不执行其中的 HTML。
7. 未读数为 0 时隐藏 Badge,1–99 显示真实值,100 及以上显示 `99+`。
## 13. 文件与审计材料中心流程
1. 仅运行时 Detail View 中可编辑的 `file` 字段显示“材料中心 / 本地上传”,并按字段 `multiple` 配置决定单值或多值。
2. 材料中心查询 `am_material` 最近 20 条,搜索时远程匹配材料标题和文件名。
3. 选中材料后只向案例草稿写入 `{name,type,size,url}`;不保存材料 ID。单值替换,多值按 URL 去重追加。
4. 本地文件先调用 `/upload`,成功后只进入当前详情编辑会话的草稿。
5. 用户保存详情时,前端先为仍保留在草稿中的本地文件创建 `am_material`,`source_app` 固定使用 `risk_case_library`;全部成功后才更新风险案例记录。
6. 取消编辑或关闭详情不创建材料主档。材料建档成功但案例保存失败时保留建档标记,重试不重复建档。
7. 清空或替换案例附件只更新案例记录,不删除共享材料中心主档。
......@@ -731,6 +731,7 @@ export function buildCasePayload(values = {}) {
content_type: 'text',
content: caseDetails,
metadata: {
...(values.metadataDraft || {}),
label,
category,
tag,
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Button, Card, Chip, SearchField, Spinner, Tabs, toast } from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi';
import {
executeNotificationAction,
extractNotificationTarget,
getNotificationContent,
getNotificationSender,
isNotificationRead,
loadNotificationPage,
loadUnreadNotificationCount,
markAllNotificationsRead,
markNotificationRead,
NOTIFICATION_PAGE_SIZE,
resolveNotificationTarget,
} from './notificationService';
const CATEGORY_OPTIONS = [{ key: 'message', label: '消息' }, { key: 'todo', label: '待办' }];
const READ_STATUS_OPTIONS = [{ key: 'all', label: '全部' }, { key: 'unread', label: '未读' }];
function formatTime(value) {
if (!value) return '时间未知';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false,
}).format(date);
}
function NotificationItem({ notification, category, currentUserId, busy, onAction, onMarkRead, onOpenTarget }) {
const read = isNotificationRead(notification, currentUserId);
const sender = getNotificationSender(notification);
const target = resolveNotificationTarget(extractNotificationTarget(notification));
const actions = Array.isArray(notification?.actions) ? notification.actions : [];
return (
<Card>
<Card.Content className="flex flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 items-start gap-3">
<Avatar className="shrink-0" size="sm"><Avatar.Fallback>{sender.trim().slice(0, 1) || '系'}</Avatar.Fallback></Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="break-words text-sm font-medium sm:text-base">{notification?.title || (category === 'todo' ? '待办通知' : '通知消息')}</h2>
<p className="mt-1 text-xs text-muted sm:text-sm">{sender} · {formatTime(notification?.sent_at || notification?.created_at)}</p>
</div>
<Chip color={read ? undefined : 'danger'} size="sm" variant="soft">{read ? (category === 'todo' ? '已完成' : '已读') : category === 'todo' ? '待处理' : '未读'}</Chip>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6">{getNotificationContent(notification) || '暂无通知内容'}</p>
</div>
</div>
<div className="flex min-h-8 flex-wrap items-center justify-end gap-2">
{!read ? <Button isDisabled={busy} size="sm" variant="secondary" onPress={() => onMarkRead(notification)}>标为已读</Button> : null}
{category === 'todo' ? actions.map((action, index) => (
<Button isDisabled={busy || read} key={`${action?.label || 'action'}-${index}`} size="sm" variant="secondary" onPress={() => onAction(notification, action)}>{action?.label || '处理'}</Button>
)) : null}
{target ? <Button isDisabled={busy} 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('');
Promise.all([
loadNotificationPage({ page, category, readStatus, keyword }),
loadUnreadNotificationCount(),
]).then(([result, count]) => {
if (cancelled) return;
setNotifications(result.list); setTotal(result.total); setUnreadTotal(count);
}).catch((requestError) => {
if (!cancelled) { setNotifications([]); setTotal(0); setError(requestError?.message || '通知列表加载失败,请稍后重试'); }
}).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [category, keyword, page, readStatus, reloadKey]);
const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const runOperation = useCallback(async (key, operation, message) => {
setBusyKey(key);
try { await operation(); toast.success(message, { timeout: 3000 }); refresh(); return true; }
catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); return false; }
finally { setBusyKey(''); }
}, [refresh]);
const 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 className="w-auto min-w-max flex-none whitespace-nowrap" id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}
</Tabs.List></Tabs.ListContainer>
</Tabs>
<Button isDisabled={loading || unreadTotal === 0} isPending={busyKey === 'all'} size="sm" variant="secondary" onPress={() => runOperation('all', markAllNotificationsRead, '全部通知已标记为已读')}>全部已读</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 className="w-auto min-w-max flex-none whitespace-nowrap" 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="danger-soft" onPress={refresh}>重试</Button></Alert> : null}
{loading && notifications.length === 0 ? <div className="flex min-h-64 items-center justify-center"><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 notification={notification} category={category} currentUserId={userInfo?._id} busy={busyKey === notification.name} key={notification.name || notification._id} onMarkRead={(item) => runOperation(item.name, () => markNotificationRead(item.name), '已标记为已读')} onAction={(item, action) => runOperation(item.name, () => executeNotificationAction(item, action), '待办已处理')} onOpenTarget={handleOpenTarget} />)}</div> : null}
{!error && total > 0 ? <DataTablePagination isDisabled={loading} itemLabel="条通知" page={page} pageSize={NOTIFICATION_PAGE_SIZE} total={total} onPageChange={setPage} /> : null}
</div>
);
}
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
import { APP_ROUTE_NAME } from '@/constants/app';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'risk-case-library:notification-state';
const APPLICATION_PATH_PREFIX = `/${APP_ROUTE_NAME}`;
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,
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,
NOTIFICATION_PAGE_SIZE,
filter,
readStatus === 'unread' ? 'unread' : null,
);
return {
list: toArray(response?.list),
total: Number(response?.total_notices ?? response?.total ?? 0) || 0,
};
}
export async function loadUnreadNotificationCount() {
const response = await unReadNoticeCount();
return Math.max(0, Number(response?.total_notices ?? response?.count ?? response?.total ?? response ?? 0) || 0);
}
export async function markNotificationRead(name) {
if (!name) throw new Error('通知缺少已读标识');
await readUserNotice(name);
notifyNotificationStateChanged();
}
export async function markAllNotificationsRead() {
await readUserNotice();
notifyNotificationStateChanged();
}
export async function executeNotificationAction(notification, action) {
const actionName = typeof action?.action === 'string'
? action.action
: action?.action?.name || action?.name;
const methodName = action?.method_name || action?.methodName;
if (!actionName || !methodName) throw new Error('该待办操作缺少执行配置');
const result = await exacteActionFunction(actionName, methodName, {
notice_id: notification?._id,
});
notifyNotificationStateChanged();
return result;
}
export function isNotificationRead(notification, userId) {
const normalizedUserId = toId(userId);
return normalizedUserId
? toArray(notification?.data?.logs).some((log) => toId(log?.user_id) === normalizedUserId)
: false;
}
export function getNotificationSender(notification) {
return notification?.from?.display_name || notification?.from?.username ||
notification?.created_by?.display_name || notification?.created_by?.username || '系统';
}
export function getNotificationContent(notification) {
return String(notification?.content || notification?.content_compile || '').trim();
}
function trimTarget(target) {
return String(target || '').replace(/[),.;!?,。;!?)]+$/u, '');
}
export function extractNotificationTarget(notification) {
const configuredTarget = notification?.settings?.url || notification?.settings?.link ||
notification?.data?.url || notification?.data?.link;
if (configuredTarget) return trimTarget(configuredTarget);
const match = getNotificationContent(notification).match(new RegExp(
`https?:\\/\\/[^\\s<>"']+|${escapeRegExp(APPLICATION_PATH_PREFIX)}\\/[^\\s<>"']+|\\/resource\\/(?:stat|booking)(?:\\?[^\\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.origin === window.location.origin && url.pathname.startsWith(`${APPLICATION_PATH_PREFIX}/`)) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch {
return null;
}
}
const appPrefix = APPLICATION_PATH_PREFIX;
if (normalizedTarget === appPrefix || normalizedTarget.startsWith(`${appPrefix}/`)) {
return { href: normalizedTarget.slice(appPrefix.length) || '/', external: false };
}
if (normalizedTarget.startsWith('/resource/') || normalizedTarget === '/notifications') {
return { href: normalizedTarget, external: false };
}
return normalizedTarget.startsWith('/') ? { href: normalizedTarget, external: true } : null;
}
export function notifyNotificationStateChanged() {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
import {
DataTable,
DataTableChip,
DataTablePagination,
DataTablePrimaryText,
DataTableSecondaryText,
DataTableText,
DataTableTextPair,
} from '@/components/DataTable';
import { TableActionButton, TABLE_ACTION_ICONS } from '@/components/TableActionButton';
import FileUploadField from '@/components/FileUploadField';
import { UserContext } from '@/wrapper/Auth';
import {
buildViewColumns,
checkViewCondition,
formatFieldValue,
getFieldNameFromPath,
getFieldValue,
shouldHideViewTitleColumn,
} from '@/utils/subjectView';
import {
Alert,
Button,
Checkbox,
Chip,
Disclosure,
Input,
......@@ -40,11 +46,16 @@ import {
import clsx from 'clsx';
import {
startTransition,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import EmptyStatePage from '../EmptyStatePage';
import RiskCaseMaterialField from '../RiskCaseMaterialField';
import { createRiskCaseMaterial } from '../riskCaseMaterialService';
import { loadRiskCaseViewResources } from '../riskCaseViewService';
import {
buildCaseDashboard,
createCaseRecord,
......@@ -67,6 +78,50 @@ const ALL_CATEGORY_KEY = '__all__';
const CARD_VIEW_MODE = 'card';
const TABLE_VIEW_MODE = 'table';
const XINYUAN_4XL_DIALOG_CLASS = 'sm:max-w-[68rem] p-7';
const FALLBACK_CASE_FIELDS = [
{ name: 'label', label: '标题', type: 'text' },
{ name: 'category', label: '分类', type: 'text' },
{ name: 'tag', label: '标签', type: 'text' },
{ name: 'case_type', label: '案例类型', type: 'text' },
{ name: 'case_attribute', label: '案例属性', type: 'text' },
{ name: 'case_details', label: '案例详情', type: 'long_text' },
{ name: 'case_warning', label: '案例警示', type: 'long_text' },
{ name: 'keyword', label: '关键字', type: 'text', multiple: true },
{ name: 'case_source', label: '案例来源', type: 'text' },
{ name: 'case_level', label: '案例等级', type: 'text' },
{ name: 'case_year', label: '案例年份', type: 'number' },
];
const FALLBACK_CASE_SUBJECT = { fields: FALLBACK_CASE_FIELDS };
const FALLBACK_GRID_VIEW = {
name: 'default-grid',
type: 'grid',
settings: {
columns: [
{ field: 'title', label: '标题', width: 380, fixed: 'left' },
{ field: 'metadata.category', label: '分类', width: 180 },
{ field: 'metadata.tag', label: '标签', width: 240 },
{ field: 'metadata.case_type', label: '案例类型', width: 220 },
{ field: 'metadata.keyword', label: '关键字', width: 280 },
{ field: 'metadata.case_source', label: '案例来源', width: 180 },
{ field: 'metadata.case_level', label: '案例等级', width: 140 },
{ field: 'metadata.case_year', label: '案例年份', width: 140 },
{ field: 'updated_at', label: '更新时间', width: 160 },
],
},
};
const FALLBACK_DETAIL_VIEW = {
name: 'base',
title: '基础信息',
type: 'detail',
settings: {
columns: [
{ field: 'title', label: '标题' },
...FALLBACK_CASE_FIELDS
.filter((field) => field.name !== 'label')
.map((field) => ({ field: `metadata.${field.name}`, label: field.label })),
],
},
};
const DEFAULT_CATEGORY_TABS = [
{
key: ALL_CATEGORY_KEY,
......@@ -326,6 +381,7 @@ function buildEmptyCaseForm() {
caseSource: '',
caseLevel: '',
caseYear: '',
metadataDraft: {},
};
}
......@@ -346,9 +402,48 @@ function buildCaseForm(caseItem) {
caseSource: caseItem.caseSource || '',
caseLevel: caseItem.caseLevel || '',
caseYear: caseItem.caseYear || '',
metadataDraft: { ...(caseItem.rawMetadata || {}) },
};
}
const FORM_FIELD_KEYS = {
label: 'label',
category: 'category',
tag: 'tag',
case_type: 'caseType',
case_attribute: 'caseAttribute',
case_details: 'caseDetails',
case_warning: 'caseWarning',
keyword: 'keyword',
case_source: 'caseSource',
case_level: 'caseLevel',
case_year: 'caseYear',
};
function getDetailFormValue(form, fieldPath) {
if (fieldPath === 'title') return form.label;
const fieldName = getFieldNameFromPath(fieldPath);
const formKey = FORM_FIELD_KEYS[fieldName];
return formKey ? form[formKey] : form.metadataDraft?.[fieldName];
}
function setDetailFormValue(form, fieldPath, value) {
if (fieldPath === 'title') return { ...form, label: value };
const fieldName = getFieldNameFromPath(fieldPath);
const formKey = FORM_FIELD_KEYS[fieldName];
if (formKey) return { ...form, [formKey]: value };
return {
...form,
metadataDraft: { ...(form.metadataDraft || {}), [fieldName]: value },
};
}
function getCaseColumnDisplayValue(item, column) {
if (column.field === 'updated_at') return item.updatedAtLabel;
if (column.field === 'created_at') return item.createdAtLabel;
return formatFieldValue(getFieldValue(item.rawRecord, column.field), column.fieldDef);
}
function CaseDetailField({ children, fullWidth = false, label, value }) {
const isEmpty = Array.isArray(value)
? value.length === 0
......@@ -415,6 +510,7 @@ function buildImportPreviewRows(preview) {
}
export default function ResourceBookingPage() {
const currentUser = useContext(UserContext);
const [query, setQuery] = useState('');
const [tagFilter, setTagFilter] = useState('');
const [caseSourceFilter, setCaseSourceFilter] = useState('');
......@@ -457,11 +553,40 @@ export default function ResourceBookingPage() {
const [batchEditForm, setBatchEditForm] = useState(buildEmptyBatchEditForm);
const [importFile, setImportFile] = useState(null);
const [importPreview, setImportPreview] = useState(null);
const [subject, setSubject] = useState(null);
const [gridViews, setGridViews] = useState([]);
const [detailViews, setDetailViews] = useState([]);
const [activeGridViewName, setActiveGridViewName] = useState('');
const [activeDetailViewName, setActiveDetailViewName] = useState('');
const [resourceError, setResourceError] = useState('');
const [materialValidationErrors, setMaterialValidationErrors] = useState({});
const pendingLocalMaterialsRef = useRef({});
const editSessionRef = useRef(0);
const activeGridView = useMemo(
() => gridViews.find((view) => view.name === activeGridViewName) || null,
[activeGridViewName, gridViews],
);
const activeCase = useMemo(() => {
return cases.find((item) => item.id === activeCaseId) || null;
}, [activeCaseId, cases]);
const visibleDetailViews = useMemo(() => {
const configured = detailViews.filter((view) =>
checkViewCondition(view?.settings?.visabled, activeCase?.rawRecord));
return configured.length > 0 ? configured : [FALLBACK_DETAIL_VIEW];
}, [activeCase?.rawRecord, detailViews]);
const activeDetailView = useMemo(
() => visibleDetailViews.find((view) => view.name === activeDetailViewName) || visibleDetailViews[0],
[activeDetailViewName, visibleDetailViews],
);
const activeDetailColumns = useMemo(
() => buildViewColumns(activeDetailView, subject || FALLBACK_CASE_SUBJECT),
[activeDetailView, subject],
);
const hideDetailTitle = shouldHideViewTitleColumn(activeDetailView);
const isDetailDirty = useMemo(
() => JSON.stringify(detailForm) !== JSON.stringify(buildCaseForm(activeCase)),
[activeCase, detailForm],
......@@ -514,100 +639,23 @@ export default function ResourceBookingPage() {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const caseTableColumns = useMemo(
() => [
{
key: 'label',
title: '标题',
width: 380,
pin: 'start',
isPrimary: true,
...buildViewColumns(
activeGridView || FALLBACK_GRID_VIEW,
subject || FALLBACK_CASE_SUBJECT,
).map((column, index) => ({
key: column.field,
title: column.label,
width: column.width,
pin: column.fixed === 'left' || column.pin === 'start' ? 'start' : undefined,
isPrimary: index === 0,
sortable: Boolean(column.sortable || column.sort),
render: (item) => (
<div className="flex min-w-0 max-w-full flex-col gap-1 overflow-hidden">
<DataTablePrimaryText
text={item.label || '未命名案例'}
showTooltip
>
{item.label || '未命名案例'}
</DataTablePrimaryText>
<DataTableSecondaryText
text={item.contentPreview || '-'}
>
{item.contentPreview || '-'}
</DataTableSecondaryText>
</div>
),
},
{
key: 'category',
title: '分类',
width: 180,
render: (item) => (
<DataTableChip color="accent" text={item.category || '-'} variant="soft">
{item.category || '-'}
</DataTableChip>
),
},
{
key: 'tag',
title: '标签',
width: 380,
render: (item) => (
item.tag ? (
<DataTableChip color="success" text={item.tag} variant="soft">
{item.tag}
</DataTableChip>
) : '-'
),
},
{
key: 'caseType',
title: '案例类型',
width: 240,
render: (item) => (
<DataTableTextPair
primary={item.caseType || '-'}
secondary={item.caseAttribute || ''}
<DataTableText
showTooltip
text={getCaseColumnDisplayValue(item, column) || '-'}
/>
),
},
{
key: 'keyword',
title: '关键字',
width: 340,
render: (item) => (
<KeywordChips
value={item.keywordList || item.keyword}
emptyText="-"
limit={3}
/>
),
},
{
key: 'caseSource',
title: '案例来源',
width: 180,
render: (item) => (
<DataTableText text={item.caseSource || '-'} showTooltip />
),
},
{
key: 'caseLevel',
title: '等级 / 年份',
width: 150,
render: (item) => (
<DataTableTextPair
primary={item.caseLevel || '-'}
secondary={item.caseYear ? `${item.caseYear} 年` : ''}
/>
),
},
{
key: 'updatedAtLabel',
title: '更新时间',
width: 150,
render: (item) => (
<DataTableText text={item.updatedAtLabel || '-'} />
),
},
})),
{
key: 'actions',
title: '操作',
......@@ -624,9 +672,40 @@ export default function ResourceBookingPage() {
),
},
],
[],
[activeGridView, subject],
);
useEffect(() => {
let cancelled = false;
setResourceError('');
loadRiskCaseViewResources()
.then((resources) => {
if (cancelled) return;
setSubject(resources.subject);
setGridViews(resources.gridViews);
setDetailViews(resources.detailViews);
setActiveGridViewName((current) =>
resources.gridViews.some((view) => view.name === current)
? current
: resources.gridViews[0]?.name || '');
})
.catch((loadError) => {
if (!cancelled) {
setSubject(null);
setGridViews([]);
setDetailViews([]);
setResourceError(loadError?.message || '案例视图配置加载失败。');
}
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!visibleDetailViews.some((view) => view.name === activeDetailViewName)) {
setActiveDetailViewName(visibleDetailViews[0]?.name || '');
}
}, [activeDetailViewName, visibleDetailViews]);
useEffect(() => {
setDetailForm(buildCaseForm(activeCase));
}, [activeCase]);
......@@ -772,6 +851,9 @@ export default function ResourceBookingPage() {
}
function openCaseDetail(caseItem) {
editSessionRef.current += 1;
pendingLocalMaterialsRef.current = {};
setMaterialValidationErrors({});
setActiveCaseId(caseItem.id);
setDetailForm(buildCaseForm(caseItem));
setIsDetailEditing(false);
......@@ -786,6 +868,8 @@ export default function ResourceBookingPage() {
}
if (!isOpen) {
pendingLocalMaterialsRef.current = {};
setMaterialValidationErrors({});
setDetailForm(buildCaseForm(activeCase));
setIsDetailEditing(false);
}
......@@ -794,11 +878,42 @@ export default function ResourceBookingPage() {
}
function handleCancelDetailEdit() {
editSessionRef.current += 1;
pendingLocalMaterialsRef.current = {};
setMaterialValidationErrors({});
setDetailForm(buildCaseForm(activeCase));
setIsDetailEditing(false);
setError('');
}
function handlePendingLocalMaterialChange(fieldPath, change, editSessionId) {
if (editSessionId !== editSessionRef.current) return;
const pendingByField = { ...pendingLocalMaterialsRef.current };
const current = Array.isArray(pendingByField[fieldPath]) ? pendingByField[fieldPath] : [];
let next = current;
if (change?.type === 'replace') next = change.materials || [];
if (change?.type === 'add') {
next = [...new Map([...current, ...(change.materials || [])]
.map((material) => [material.clientId, material])).values()];
}
if (change?.type === 'remove') {
next = current.filter((material) => material.clientId !== change.clientId);
}
if (next.length > 0) pendingByField[fieldPath] = next;
else delete pendingByField[fieldPath];
pendingLocalMaterialsRef.current = pendingByField;
}
function handleMaterialValidation(fieldPath, message, editSessionId) {
if (editSessionId !== editSessionRef.current) return;
setMaterialValidationErrors((current) => {
const next = { ...current };
if (message) next[fieldPath] = message;
else delete next[fieldPath];
return next;
});
}
async function handleCreateSubmit() {
if (
!String(createForm.label || '').trim() &&
......@@ -847,11 +962,32 @@ export default function ResourceBookingPage() {
setNotice('');
try {
for (const [fieldPath, pendingMaterials] of Object.entries(pendingLocalMaterialsRef.current)) {
const currentValue = getDetailFormValue(detailForm, fieldPath);
const retainedUrls = new Set((Array.isArray(currentValue) ? currentValue : currentValue ? [currentValue] : [])
.map((file) => file?.url).filter(Boolean));
for (const pendingMaterial of pendingMaterials) {
if (pendingMaterial.materialRecordId || !retainedUrls.has(pendingMaterial.fileAsset?.url)) continue;
const createdMaterial = await createRiskCaseMaterial({
fileAsset: pendingMaterial.fileAsset,
currentUser,
});
pendingLocalMaterialsRef.current = {
...pendingLocalMaterialsRef.current,
[fieldPath]: (pendingLocalMaterialsRef.current[fieldPath] || []).map((material) =>
material.clientId === pendingMaterial.clientId
? { ...material, materialRecordId: createdMaterial.id }
: material),
};
}
}
const updated = await updateCaseRecord(activeCase, detailForm);
setNotice(`案例“${updated.label}”已更新。`);
await refreshCurrentList();
setActiveCaseId(updated.id || activeCase.id);
setIsDetailEditing(false);
pendingLocalMaterialsRef.current = {};
setMaterialValidationErrors({});
} catch (saveError) {
setError(saveError?.message || '案例保存失败,请稍后重试。');
} finally {
......@@ -1095,6 +1231,36 @@ export default function ResourceBookingPage() {
return (
<div className="space-y-6">
{resourceError ? (
<Alert status="warning">
<Alert.Content>
<Alert.Description>{resourceError} 当前继续使用默认案例字段展示。</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{gridViews.length > 1 ? (
<Tabs
className="w-fit max-w-full min-w-0"
selectedKey={activeGridViewName}
variant="primary"
onSelectionChange={(key) => {
setActiveGridViewName(String(key));
setPage(0);
setSelectedCaseIds([]);
}}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="案例视图" className="w-max min-w-max">
{gridViews.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}
<div className="min-w-0 max-w-full">
<Tabs
className="w-fit max-w-full min-w-0"
......@@ -1578,195 +1744,69 @@ export default function ResourceBookingPage() {
<Modal.Body>
{activeCase ? (
<div className="space-y-6">
{!hideDetailTitle ? (
<h2 className="break-words text-lg font-semibold">{activeCase.title || activeCase.label}</h2>
) : null}
<div className="flex flex-wrap gap-x-4 gap-y-2">
<span>创建于 {activeCase.createdAtLabel}</span>
<span>最近更新 {activeCase.updatedAtLabel}</span>
</div>
{visibleDetailViews.length > 1 ? (
<Tabs selectedKey={activeDetailView?.name} onSelectionChange={(key) => setActiveDetailViewName(String(key))}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="详情视图" className="w-max min-w-max">
{visibleDetailViews.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}
{!isDetailEditing ? (
<Surface
className="px-5 py-2 sm:px-6 sm:py-3"
variant="secondary"
>
<div className="grid min-w-0 grid-cols-1 gap-x-8 gap-y-2 md:grid-cols-2">
<CaseDetailField fullWidth label="标题" value={activeCase.label} />
<CaseDetailField label="分类" value={activeCase.category} />
<CaseDetailField label="标签" value={activeCase.tag} />
<CaseDetailField label="案例类型" value={activeCase.caseType} />
<CaseDetailField label="案例属性" value={activeCase.caseAttribute} />
<CaseDetailField fullWidth label="关键字" value={activeCase.keywordList}>
<KeywordChips value={activeCase.keywordList} />
</CaseDetailField>
<CaseDetailField label="案例来源" value={activeCase.caseSource} />
<CaseDetailField label="案例等级" value={activeCase.caseLevel} />
<CaseDetailField label="案例年份" value={activeCase.caseYear} />
<CaseDetailField fullWidth label="案例详情" value={activeCase.caseDetails} />
<CaseDetailField fullWidth label="案例警示" value={activeCase.caseWarning} />
{activeDetailColumns.map((column) => {
const value = getFieldValue(activeCase.rawRecord, column.field);
const fieldType = String(column.fieldDef?.type || '').toLowerCase();
const fullWidth = ['long_text', 'rich_text', 'object', 'subject', 'file'].includes(fieldType);
return (
<CaseDetailField fullWidth={fullWidth} key={column.field} label={column.label} value={value}>
{formatFieldValue(value, column.fieldDef) || '暂无内容'}
</CaseDetailField>
);
})}
</div>
</Surface>
) : (
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<TextField className="md:col-span-2">
<Label>标题</Label>
<Input
fullWidth
value={detailForm.label}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
label: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>分类</Label>
<Input
fullWidth
value={detailForm.category}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
category: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>标签</Label>
<Input
fullWidth
value={detailForm.tag}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
tag: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例类型</Label>
<Input
fullWidth
value={detailForm.caseType}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseType: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例属性</Label>
<Input
fullWidth
value={detailForm.caseAttribute}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseAttribute: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>关键字</Label>
<KeywordEditor
value={detailForm.keyword}
onChange={(nextKeywords) =>
setDetailForm((prev) => ({
...prev,
keyword: nextKeywords,
}))
}
placeholder="输入关键字后按 Enter 新增"
/>
</TextField>
<TextField>
<Label>案例来源</Label>
<Input
fullWidth
value={detailForm.caseSource}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseSource: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例等级</Label>
<Input
fullWidth
value={detailForm.caseLevel}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseLevel: event.target.value,
}))
}
/>
</TextField>
<TextField>
<Label>案例年份</Label>
<Input
fullWidth
type="number"
value={detailForm.caseYear}
variant="secondary"
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseYear: event.target.value,
}))
}
/>
</TextField>
<TextField className="md:col-span-2">
<div className="mb-2 flex items-center justify-between gap-3">
<Label>案例详情</Label>
<span>
当前 {String(detailForm.caseDetails || '').replace(/\s+/g, '').length} 字
</span>
</div>
<TextArea
fullWidth
rows={16}
variant="secondary"
value={detailForm.caseDetails}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseDetails: event.target.value,
}))
}
/>
</TextField>
<TextField className="md:col-span-2">
<Label>案例警示</Label>
<TextArea
fullWidth
rows={4}
variant="secondary"
value={detailForm.caseWarning}
onChange={(event) =>
setDetailForm((prev) => ({
...prev,
caseWarning: event.target.value,
}))
}
/>
</TextField>
{activeDetailColumns.map((column) => {
const fieldType = String(column.fieldDef?.type || 'text').toLowerCase();
const fieldName = getFieldNameFromPath(column.field);
const value = getDetailFormValue(detailForm, column.field);
const disabled = Boolean(column.fieldDef?.isPrimaryKey);
const fullWidth = ['long_text', 'rich_text', 'object', 'subject', 'file'].includes(fieldType);
const label = `${column.label}${column.fieldDef?.isRequired ? ' *' : ''}`;
if (fieldType === 'file') {
return <div className={clsx('min-w-0', fullWidth && 'md:col-span-2')} key={column.field}><Label>{label}</Label><div className="mt-2">{disabled ? <span className="text-sm text-muted">{formatFieldValue(value, column.fieldDef) || '未上传'}</span> : <RiskCaseMaterialField editSessionId={editSessionRef.current} fieldPath={column.field} multiple={Boolean(column.fieldDef?.multiple || column.fieldDef?.settings?.multiple)} value={value} onChange={(nextValue) => setDetailForm((current) => setDetailFormValue(current, column.field, nextValue))} onPendingLocalMaterialChange={handlePendingLocalMaterialChange} onValidationChange={handleMaterialValidation} />}</div></div>;
}
if (fieldName === 'keyword') {
return <TextField className={fullWidth ? 'md:col-span-2' : undefined} isDisabled={disabled} key={column.field}><Label>{label}</Label><KeywordEditor value={value} onChange={(nextValue) => setDetailForm((current) => setDetailFormValue(current, column.field, nextValue))} /></TextField>;
}
if (fieldType === 'boolean') {
return <Checkbox isDisabled={disabled} isSelected={Boolean(value)} key={column.field} onChange={(selected) => setDetailForm((current) => setDetailFormValue(current, column.field, selected))}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{label}</Checkbox.Content></Checkbox>;
}
if (['long_text', 'rich_text'].includes(fieldType) || ['case_details', 'case_warning'].includes(fieldName)) {
return <TextField className="md:col-span-2" isDisabled={disabled} key={column.field}><Label>{label}</Label><TextArea fullWidth rows={fieldName === 'case_details' ? 16 : 4} variant="secondary" value={value || ''} onChange={(event) => setDetailForm((current) => setDetailFormValue(current, column.field, event.target.value))} /></TextField>;
}
return <TextField isDisabled={disabled} key={column.field}><Label>{label}</Label><Input fullWidth type={fieldType === 'number' ? 'number' : 'text'} variant="secondary" value={value ?? ''} onChange={(event) => setDetailForm((current) => setDetailFormValue(current, column.field, event.target.value))} /></TextField>;
})}
</div>
)}
</div>
......@@ -1797,7 +1837,7 @@ export default function ResourceBookingPage() {
取消
</Button>
<Button
isDisabled={saving || !activeCase || !isDetailDirty}
isDisabled={saving || !activeCase || !isDetailDirty || Object.keys(materialValidationErrors).length > 0}
size="sm"
variant="primary"
onPress={handleSaveDetail}
......
import dayjs from 'dayjs';
import { APP_ROUTE_NAME } from '@/constants/app';
import { createRecord, queryRecords, uploadFile } from '@/services/DataService';
export const AUDIT_MATERIAL_SUBJECT = 'am_material';
export const RISK_CASE_SOURCE_APP = APP_ROUTE_NAME;
const MATERIAL_PAGE_SIZE = 20;
function extractRecords(response) {
return [
response,
response?.data,
response?.data?.list,
response?.data?.records,
response?.list,
response?.records,
response?.items,
response?.result,
response?.result?.records,
].find(Array.isArray) || [];
}
function escapeRegExp(value = '') {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function normalizeFileAsset(uploadResult, file) {
const candidates = [
uploadResult?.file,
uploadResult?.data?.file,
uploadResult?.data,
uploadResult?.result?.file,
uploadResult?.result,
uploadResult,
];
for (const candidate of candidates) {
if (!candidate || typeof candidate !== 'object') continue;
const url = candidate.url || candidate.path || candidate.location || candidate?.data?.url;
if (!url) continue;
return {
name: candidate.name || candidate.filename || file?.name || '未命名文件',
type: candidate.type || candidate.mimetype || file?.type || 'application/octet-stream',
size: Number(candidate.size || file?.size || 0),
url,
};
}
throw new Error('文件上传成功,但未获取到可用的文件地址。');
}
function normalizeMaterial(record = {}) {
const metadata = record.metadata || {};
const fileAsset = metadata.file_asset || {};
return {
id: record._id || record.id || record.name || '',
materialTitle: metadata.material_title || record.title || fileAsset.name || '未命名材料',
fileAsset,
archiveCategory: metadata.archive_category || '',
archiveYear: metadata.archive_year || '',
};
}
export async function queryRiskCaseMaterials(keyword = '') {
const escapedKeyword = escapeRegExp(String(keyword || '').trim());
const filter = escapedKeyword ? {
$or: [
{ 'metadata.material_title': { $regex: escapedKeyword, $options: 'i' } },
{ 'metadata.file_asset.name': { $regex: escapedKeyword, $options: 'i' } },
],
} : {};
const response = await queryRecords({
subject: AUDIT_MATERIAL_SUBJECT,
page: 0,
page_size: MATERIAL_PAGE_SIZE,
filter,
sort: '-updated_at -created_at',
mode: 'full',
});
return extractRecords(response).map(normalizeMaterial)
.filter((material) => material.id && material.fileAsset?.url);
}
export async function uploadRiskCaseFile(file) {
if (!file) throw new Error('请选择要上传的本地文件。');
return normalizeFileAsset(await uploadFile(file), file);
}
export async function createRiskCaseMaterial({ fileAsset, currentUser }) {
if (!fileAsset?.url) throw new Error('缺少有效的文件地址,无法写入审计材料中心。');
const uploaderId = currentUser?._id || currentUser?.id;
if (!uploaderId) throw new Error('未获取到当前登录用户,无法写入审计材料中心。');
const materialTitle = String(fileAsset.name || '').replace(/\.[^.]+$/, '').trim() || '未命名材料';
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: RISK_CASE_SOURCE_APP,
uploader: uploaderId,
uploaded_at: dayjs().toISOString(),
},
});
const created = response?.data || response?.result || response;
return { id: created?._id || created?.id || created?.name || true, record: created };
}
import {
getSubjectView,
getSubjectViewDetail,
loadSubject,
} from '@/services/DataService';
import { toArray } from '@/utils/subjectView';
import { CASE_SUBJECT } from './materialService';
export async function loadRiskCaseViewResources() {
const [subject, viewSummaries] = await Promise.all([
loadSubject(CASE_SUBJECT),
getSubjectView(CASE_SUBJECT),
]);
const views = await Promise.all(toArray(viewSummaries).map(async (view) => {
if (!view?.name) return view;
try {
const detail = await getSubjectViewDetail(CASE_SUBJECT, view.name);
return {
...view,
...detail,
settings: detail?.settings || view?.settings || {},
};
} catch {
return view;
}
}));
return {
subject,
views,
gridViews: views.filter((view) => view?.type === 'grid'),
detailViews: views.filter((view) => view?.type === 'detail'),
};
}
export function normalizeText(value) {
return String(value ?? '').trim();
}
export function toArray(value) {
if (value === null || value === undefined || value === '') return [];
return Array.isArray(value) ? value : [value];
}
export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
export function getFieldValue(record, fieldPath) {
if (!record || !fieldPath) return null;
return fieldPath.split('.').reduce((value, key) => value?.[key], record);
}
export function getFieldDefinition(fieldPath, subject) {
if (fieldPath === 'title') return { name: 'title', label: '标题', type: 'text' };
const fieldName = String(fieldPath || '').replace(/^metadata\./, '').split('.').pop();
return toArray(subject?.fields).find((field) => field?.name === fieldName) || null;
}
function getColumnLabel(column, fieldDefinition) {
return [column?.label, column?.title, column?.header, column?.displayName, column?.display_name]
.map(normalizeText)
.find(Boolean) || fieldDefinition?.label || column?.field || '未命名字段';
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
const hideTitle = !includeHidden && shouldHideViewTitleColumn(view);
const configuredColumns = toArray(view?.settings?.columns);
const columns = configuredColumns.length > 0
? configuredColumns
: [
{ field: 'title', width: 240 },
...toArray(subject?.fields).map((field) => ({
field: `metadata.${field.name}`,
width: 180,
})),
];
return columns
.filter((column) => column?.field && (includeHidden || !column.hide))
.filter((column) => !hideTitle || column.field !== 'title')
.map((column) => {
const fieldDef = getFieldDefinition(column.field, subject) || {
name: column.field,
label: column.field,
type: 'text',
};
return {
...column,
label: getColumnLabel(column, fieldDef),
width: Number(column.width) || 180,
fieldDef,
};
});
}
function compareValue(recordValue, comparator, expectedValue) {
const actual = toArray(recordValue).map((value) => normalizeText(
value && typeof value === 'object'
? value._id || value.name || value.title || value.display_name || value.username
: value,
));
const expected = toArray(expectedValue).map(normalizeText);
if (comparator === '$nin' || comparator === '$ne') {
return !actual.some((value) => expected.includes(value));
}
if (comparator === '$exists') {
return Boolean(expectedValue) ? recordValue != null : recordValue == null;
}
if (comparator === '$contains') {
return actual.some((value) => value.includes(expected.join(' ')));
}
return actual.some((value) => expected.includes(value));
}
export function checkViewCondition(condition, record) {
if (condition === undefined || condition === null) return true;
if (typeof condition === 'boolean') return condition;
const rules = toArray(condition?.rules || condition?.conditions);
if (rules.length === 0) return true;
const results = rules.map((rule) => compareValue(
getFieldValue(record, rule.field),
rule.comparator || '$eq',
rule.value,
));
return condition.relationship === 'any' || condition.relationship === 'or'
? results.some(Boolean)
: results.every(Boolean);
}
export function formatFieldValue(value, fieldDef) {
if (value === null || value === undefined || value === '') return '';
if (Array.isArray(value)) {
return value
.map((item) => formatFieldValue(item, { ...fieldDef, multiple: false }))
.filter(Boolean)
.join('、');
}
if (fieldDef?.type === 'boolean') return value ? '是' : '否';
if (fieldDef?.type === 'file') return value?.name || value?.url || '';
if (typeof value === 'object') {
return value.title || value.display_name || value.username || value.name || value._id || JSON.stringify(value);
}
return String(value);
}
export function getFieldNameFromPath(fieldPath) {
return String(fieldPath || '').replace(/^metadata\./, '').split('.').pop();
}
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