Commit 72cf97b2 authored by drigle's avatar drigle

feat: unify feedback and display archived department snapshots

Centralize operation feedback with HeroUI Toast and retain load retry states. Render nested archived department details with saved-value preservation and display-name lookups. Use the Chinese application name for material sources, bump the manifest version to 1.0.5, and update flow and design documentation.
parent adccd409
...@@ -73,3 +73,9 @@ ...@@ -73,3 +73,9 @@
- `settings.shortTitle` 只接受严格布尔值 `true`。Grid 隐藏的标题仍参与关键词搜索;Detail 按当前激活 View 同步隐藏记录标题和 title 字段,但始终保留 Modal 功能标题及可访问名称。 - `settings.shortTitle` 只接受严格布尔值 `true`。Grid 隐藏的标题仍参与关键词搜索;Detail 按当前激活 View 同步隐藏记录标题和 title 字段,但始终保留 Modal 功能标题及可访问名称。
- 通知入口固定使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Ant Bell 位于主题按钮左侧;通知页面沿用平台协议并只渲染纯文本。 - 通知入口固定使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Ant Bell 位于主题按钮左侧;通知页面沿用平台协议并只渲染纯文本。
- 可编辑 file 字段使用 HeroUI Tabs、Autocomplete 和 HeroUI Pro DropZone。材料选择只保存文件快照;本地上传在详情保存时以 `source_app = integrity_risk_prevention` 延迟创建材料主档,并保留失败重试去重标记。 - 可编辑 file 字段使用 HeroUI Tabs、Autocomplete 和 HeroUI Pro DropZone。材料选择只保存文件快照;本地上传在详情保存时以 `source_app = integrity_risk_prevention` 延迟创建材料主档,并保留失败重试去重标记。
## 操作反馈浮层
接口成功、失败及降级警告统一使用应用根部 `AppFeedback` 的 HeroUI `Toast.Provider` 和官方 compound 组件。登录、独立填报流程页和 AppShell 共用一个 Provider,页面不再渲染接口反馈横条;成功默认 4 秒、错误/警告默认 8 秒后消失,关闭提示不改变请求结果或表单草稿。
`src/design-system/feedback.css` 对齐问题整改提交 `bfc5862`,仅调整 Toast 层级、安全区位置及后排提示关闭按钮的可见性,浮层层级为 300,高于业务 Modal、确认框和移动侧栏。关闭按钮直接组合 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`),通过官方 `slot="close"` 接入 Toast 关闭行为,使用 `AppIcons.Close`,位于内容右侧并随正文参与布局。前排关闭图标始终可见,使用官方透明按钮及默认悬停/焦点样式;不再放大带边框的 Toast 默认关闭按钮,不设置额外绝对位置和正文右侧占位。颜色、边框、圆角、阴影与悬停继续使用 HeroUI 默认主题。加载失败使用 EmptyState 和读取重试入口,必填/JSON 等字段校验及业务确认说明仍在原位置展示。
{ {
"name": "integrity_risk_prevention_new", "name": "integrity_risk_prevention_new",
"version": "1.0.3", "version": "1.0.5",
"build": "abc", "build": "abc",
"description": "廉洁风险防控", "description": "廉洁风险防控",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import '@/global.less'; import '@/global.less';
import '@/tailwind.css'; import '@/tailwind.css';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
APPLICATION_NAME, APPLICATION_NAME,
...@@ -34,4 +36,8 @@ export async function getInitialState() { ...@@ -34,4 +36,8 @@ export async function getInitialState() {
}; };
} }
export default defineApp({}); export function rootContainer(container) {
return createElement(AppFeedback, null, container);
}
export default defineApp({ rootContainer });
import { Button, Toast } from '@heroui/react';
import { Close } from '@/components/AppIcons';
export default function AppFeedback({ children }) {
return (
<>
{children}
<Toast.Provider
aria-label="操作提示"
className="app-feedback-region"
maxVisibleToasts={3}
placement="top"
width="min(30rem, calc(100vw - 2rem))"
>
{({ toast }) => (
<Toast className="app-feedback-toast gap-2" toast={toast} variant={toast.content.variant}>
<Toast.Indicator variant={toast.content.variant} />
<Toast.Content className="min-w-0">
<Toast.Title>{toast.content.title}</Toast.Title>
{toast.content.description ? (
<Toast.Description className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground">
{toast.content.description}
</Toast.Description>
) : null}
{toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null}
</Toast.Content>
<Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast>
)}
</Toast.Provider>
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi'; import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar'; import DashboardSidebar from './DashboardSidebar';
...@@ -32,7 +31,6 @@ export default function AppShell({ ...@@ -32,7 +31,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div> <div className="rc-app-page">{children}</div>
</div> </div>
</Sidebar.Main> </Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </Sidebar.Provider>
); );
} }
import useFeedbackState from '@/hooks/useFeedbackState';
import { DataTableEmptyState } from '@/components/DataTable';
import { Alert, Button, Checkbox, Input, Label, Surface, TextArea, TextField, Tooltip } from '@heroui/react'; import { Alert, Button, Checkbox, Input, Label, Surface, TextArea, TextField, Tooltip } from '@heroui/react';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect'; import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
...@@ -118,7 +120,7 @@ function JsonInput({ value, onChange, placeholder }) { ...@@ -118,7 +120,7 @@ function JsonInput({ value, onChange, placeholder }) {
function FileInput({ fieldDef, value, onChange }) { function FileInput({ fieldDef, value, onChange }) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const handleUpload = async (files) => { const handleUpload = async (files) => {
const file = files?.[0]; const file = files?.[0];
...@@ -167,14 +169,14 @@ function FileInput({ fieldDef, value, onChange }) { ...@@ -167,14 +169,14 @@ function FileInput({ fieldDef, value, onChange }) {
triggerLabel="选择附件" triggerLabel="选择附件"
/> />
{uploading ? <div className="text-sm text-muted">上传中...</div> : null} {uploading ? <div className="text-sm text-muted">上传中...</div> : null}
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
</div> </div>
); );
} }
function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
const [nestedSubject, setNestedSubject] = useState(null); const [nestedSubject, setNestedSubject] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
const subjectName = getReferenceSubjectName(fieldDef); const subjectName = getReferenceSubjectName(fieldDef);
const nestedLabel = getFieldDisplayLabel(fieldDef); const nestedLabel = getFieldDisplayLabel(fieldDef);
...@@ -185,6 +187,8 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -185,6 +187,8 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return undefined; return undefined;
} }
setError('');
setNestedSubject(null);
loadNestedSubjectDefinition(subjectName) loadNestedSubjectDefinition(subjectName)
.then((result) => { .then((result) => {
if (!cancelled) { if (!cancelled) {
...@@ -201,7 +205,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -201,7 +205,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [subjectName, depth]); }, [subjectName, depth, retryKey, setError]);
if (!subjectName || depth >= MAX_SUBJECT_DEPTH) { if (!subjectName || depth >= MAX_SUBJECT_DEPTH) {
return ( return (
...@@ -214,7 +218,12 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -214,7 +218,12 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
} }
if (error) { if (error) {
return <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>; return (
<div className="space-y-2">
<DataTableEmptyState title="子对象结构加载失败,详细错误见操作提示" />
<Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>重新加载</Button>
</div>
);
} }
if (!nestedSubject) { if (!nestedSubject) {
...@@ -506,6 +515,7 @@ export default function DynamicRecordDialog({ ...@@ -506,6 +515,7 @@ export default function DynamicRecordDialog({
const [values, setValues] = useState({}); const [values, setValues] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const editing = Boolean(record); const editing = Boolean(record);
const hideTitle = shouldHideViewTitleColumn(view); const hideTitle = shouldHideViewTitleColumn(view);
const dialogColumns = useMemo(() => getDialogColumns(subject, view), [subject, view]); const dialogColumns = useMemo(() => getDialogColumns(subject, view), [subject, view]);
...@@ -533,6 +543,7 @@ export default function DynamicRecordDialog({ ...@@ -533,6 +543,7 @@ export default function DynamicRecordDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setRequestError('');
return; return;
} }
setTitle(record?.title || record?.metadata?.project_name || ( setTitle(record?.title || record?.metadata?.project_name || (
...@@ -540,11 +551,12 @@ export default function DynamicRecordDialog({ ...@@ -540,11 +551,12 @@ export default function DynamicRecordDialog({
)); ));
setValues(initialValues); setValues(initialValues);
setError(''); setError('');
}, [open, record, initialValues, editing, restrictionDefaults]); }, [open, record, initialValues, editing, restrictionDefaults, setRequestError]);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
setError(''); setError('');
setRequestError('');
try { try {
const prepared = await prepareValuesForSubmit(subject, values); const prepared = await prepareValuesForSubmit(subject, values);
const validationErrors = [...prepared.errors]; const validationErrors = [...prepared.errors];
...@@ -577,7 +589,7 @@ export default function DynamicRecordDialog({ ...@@ -577,7 +589,7 @@ export default function DynamicRecordDialog({
await onSaved?.(); await onSaved?.();
onClose?.(); onClose?.();
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试'); setRequestError(saveError?.message || '保存失败,请稍后重试');
} finally { } finally {
setSaving(false); setSaving(false);
} }
......
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { queryRecords } from '@/services/DataService'; import { queryRecords } from '@/services/DataService';
import { Button, Label } from '@heroui/react'; import { Button, Label } from '@heroui/react';
...@@ -326,7 +327,7 @@ export default function ReferenceSearchSelect({ ...@@ -326,7 +327,7 @@ export default function ReferenceSearchSelect({
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [selectedById, setSelectedById] = useState({}); const [selectedById, setSelectedById] = useState({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const lastSearchRequestId = useRef(0); const lastSearchRequestId = useRef(0);
...@@ -493,7 +494,7 @@ export default function ReferenceSearchSelect({ ...@@ -493,7 +494,7 @@ export default function ReferenceSearchSelect({
cancelled = true; cancelled = true;
clearTimeout(timer); clearTimeout(timer);
}; };
}, [active, pageSize, query, queryFilter, searchFields, subject]); }, [active, pageSize, query, queryFilter, searchFields, subject, setError]);
useEffect(() => { useEffect(() => {
if (options.length === 0) { if (options.length === 0) {
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Spinner } from '@heroui/react'; import { Button, Spinner } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
...@@ -196,10 +197,11 @@ export default function SubjectRecordWorkbench({ ...@@ -196,10 +197,11 @@ export default function SubjectRecordWorkbench({
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]); const [views, setViews] = useState([]);
const [tableDisplayConfig, setTableDisplayConfig] = useState({}); const [tableDisplayConfig, setTableDisplayConfig] = useState({});
const [tableDisplayConfigError, setTableDisplayConfigError] = useState(''); const [, setTableDisplayConfigError] = useFeedbackState('warning');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [success, setSuccess] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [, setSuccess] = useFeedbackState('success');
const [activeViewName, setActiveViewName] = useState(''); const [activeViewName, setActiveViewName] = useState('');
const [filters, setFilters] = useState({}); const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({}); const [pendingFilters, setPendingFilters] = useState({});
...@@ -213,6 +215,7 @@ export default function SubjectRecordWorkbench({ ...@@ -213,6 +215,7 @@ export default function SubjectRecordWorkbench({
const reload = useCallback(async ({ keepFeedback = false } = {}) => { const reload = useCallback(async ({ keepFeedback = false } = {}) => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError(''); setError('');
if (!keepFeedback) { if (!keepFeedback) {
setSuccess(''); setSuccess('');
...@@ -226,6 +229,7 @@ export default function SubjectRecordWorkbench({ ...@@ -226,6 +229,7 @@ export default function SubjectRecordWorkbench({
setTableDisplayConfig(resources.displayConfig || {}); setTableDisplayConfig(resources.displayConfig || {});
setTableDisplayConfigError(resources.displayConfigError || ''); setTableDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) { } catch (requestError) {
setLoadFailed(true);
setRecords([]); setRecords([]);
setSubject(null); setSubject(null);
setViews([]); setViews([]);
...@@ -235,7 +239,7 @@ export default function SubjectRecordWorkbench({ ...@@ -235,7 +239,7 @@ export default function SubjectRecordWorkbench({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [activeViewName, filters, subjectName, query]); }, [activeViewName, filters, subjectName, query, setError, setSuccess, setTableDisplayConfigError]);
useEffect(() => { useEffect(() => {
reload(); reload();
...@@ -360,7 +364,7 @@ export default function SubjectRecordWorkbench({ ...@@ -360,7 +364,7 @@ export default function SubjectRecordWorkbench({
setSuccess, setSuccess,
subject, subject,
subjectName, subjectName,
}), [reload, subject, subjectName]); }), [reload, subject, subjectName, setError, setSuccess]);
const wrapAction = (action) => ({ const wrapAction = (action) => ({
...action, ...action,
...@@ -498,7 +502,7 @@ export default function SubjectRecordWorkbench({ ...@@ -498,7 +502,7 @@ export default function SubjectRecordWorkbench({
); );
} }
if (!loading && !error && !hasAccessibleGridView) { if (!loading && !loadFailed && !hasAccessibleGridView) {
return ( return (
<div className="flex min-h-64 items-center justify-center"> <div className="flex min-h-64 items-center justify-center">
<EmptyState size="lg"> <EmptyState size="lg">
...@@ -570,9 +574,8 @@ export default function SubjectRecordWorkbench({ ...@@ -570,9 +574,8 @@ export default function SubjectRecordWorkbench({
}} }}
states={{ states={{
loading, loading,
error, loadFailed,
warning: tableDisplayConfigError, onRetry: () => reload(),
success,
loadingTitle: `正在加载${title}`, loadingTitle: `正在加载${title}`,
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。', loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: hasAccessibleGridView ? '当前视图暂无数据' : '暂无可访问视图', emptyTitle: hasAccessibleGridView ? '当前视图暂无数据' : '暂无可访问视图',
......
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Label } from '@heroui/react'; import { Label } from '@heroui/react';
import { loadUser, queryUsers } from '@/services/DataService'; import { loadUser, queryUsers } from '@/services/DataService';
...@@ -28,6 +29,7 @@ export default function UserSearchSelect({ ...@@ -28,6 +29,7 @@ export default function UserSearchSelect({
const [profiles, setProfiles] = useState({}); const [profiles, setProfiles] = useState({});
const [failedLookupIds, setFailedLookupIds] = useState(new Set()); const [failedLookupIds, setFailedLookupIds] = useState(new Set());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState('error');
const userLookupPromisesRef = useRef(new Map()); const userLookupPromisesRef = useRef(new Map());
const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]); const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]);
...@@ -46,6 +48,7 @@ export default function UserSearchSelect({ ...@@ -46,6 +48,7 @@ export default function UserSearchSelect({
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
setError('');
const text = keyword.trim(); const text = keyword.trim();
queryUsers(0, 30, text ? { queryUsers(0, 30, text ? {
$or: [ $or: [
...@@ -62,14 +65,17 @@ export default function UserSearchSelect({ ...@@ -62,14 +65,17 @@ export default function UserSearchSelect({
...Object.fromEntries(nextUsers.map((user) => [getUserId(user), user])), ...Object.fromEntries(nextUsers.map((user) => [getUserId(user), user])),
})); }));
}) })
.catch(() => { .catch((requestError) => {
if (!cancelled) setUsers([]); if (!cancelled) {
setUsers([]);
setError(requestError?.message || '用户列表加载失败,请重新搜索。');
}
}) })
.finally(() => { .finally(() => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [keyword]); }, [keyword, setError]);
useEffect(() => { useEffect(() => {
const missing = selectedIds.filter( const missing = selectedIds.filter(
...@@ -152,6 +158,7 @@ export default function UserSearchSelect({ ...@@ -152,6 +158,7 @@ export default function UserSearchSelect({
<XinyuanAutocomplete <XinyuanAutocomplete
aria-label={ariaLabel} aria-label={ariaLabel}
inputValue={keyword} inputValue={keyword}
errorText={error}
isDisabled={disabled} isDisabled={disabled}
isLoading={loading} isLoading={loading}
options={options} options={options}
......
import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
DataTable, DataTable,
DataTablePagination, DataTablePagination,
...@@ -36,7 +37,6 @@ import { ...@@ -36,7 +37,6 @@ import {
resolveFieldValue, resolveFieldValue,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Chip, Chip,
...@@ -49,7 +49,6 @@ import { ...@@ -49,7 +49,6 @@ import {
ToggleButton, ToggleButton,
Tooltip, Tooltip,
buttonVariants, buttonVariants,
toast,
} from '@heroui/react'; } from '@heroui/react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
...@@ -579,13 +578,8 @@ export default function RecordWorkbench({ ...@@ -579,13 +578,8 @@ export default function RecordWorkbench({
const [expandedRowIds, setExpandedRowIds] = useState({}); const [expandedRowIds, setExpandedRowIds] = useState({});
const filterFieldVariant = 'secondary'; const filterFieldVariant = 'secondary';
const loading = Boolean(states?.loading); const loading = Boolean(states?.loading);
const error = states?.error || ''; const loadFailed = Boolean(states?.loadFailed);
const totalRecords = pagination?.totalRecords ?? records.length; const totalRecords = pagination?.totalRecords ?? records.length;
useEffect(() => {
if (states?.success) {
toast.success(states.success, { timeout: 4000 });
}
}, [states?.success]);
const columnGroupMeta = useMemo( const columnGroupMeta = useMemo(
() => buildColumnGroupMeta(columnGroups, columns), () => buildColumnGroupMeta(columnGroups, columns),
[columnGroups, columns], [columnGroups, columns],
...@@ -1254,19 +1248,19 @@ export default function RecordWorkbench({ ...@@ -1254,19 +1248,19 @@ export default function RecordWorkbench({
) : null} ) : null}
</div> </div>
{error ? ( {!loading && loadFailed ? (
<Alert status="danger"> <EmptyState className="min-h-48" size="md">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content> <EmptyState.Header>
</Alert> <EmptyState.Title>{title || '列表'}加载失败</EmptyState.Title>
) : null} <EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
{states?.warning ? ( <EmptyState.Content>
<Alert status="warning"> <Button variant="secondary" onPress={states.onRetry || onQuery}>重新加载</Button>
<Alert.Content><Alert.Description>{states.warning}</Alert.Description></Alert.Content> </EmptyState.Content>
</Alert> </EmptyState>
) : null} ) : null}
<div ref={tableScrollRef} className={tableContentClass}> <div ref={tableScrollRef} className={!loading && loadFailed ? 'hidden' : tableContentClass}>
<DataTable <DataTable
ariaLabel={title || '业务数据列表'} ariaLabel={title || '业务数据列表'}
className={stickyTable ? 'rc-data-table-sticky' : ''} className={stickyTable ? 'rc-data-table-sticky' : ''}
......
...@@ -2,7 +2,7 @@ export const APPLICATION_DISPLAY_NAME = '廉洁风险防控'; ...@@ -2,7 +2,7 @@ export const APPLICATION_DISPLAY_NAME = '廉洁风险防控';
export const APPLICATION_NAME = 'integrity_risk_prevention_new'; export const APPLICATION_NAME = 'integrity_risk_prevention_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`; export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME; export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const AUDIT_MATERIAL_SOURCE_APP = 'integrity_risk_prevention'; export const AUDIT_MATERIAL_SOURCE_APP = APPLICATION_DISPLAY_NAME;
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
'/risk_prevention_control_measures_table': '廉洁风险防控表 (过程表)', '/risk_prevention_control_measures_table': '廉洁风险防控表 (过程表)',
......
/* Keep operation feedback above Modal and the mobile sidebar, outside page scrolling. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
visibility: hidden;
pointer-events: none;
}
import { toast } from '@heroui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
const FEEDBACK_OPTIONS = {
error: { title: '操作失败', variant: 'danger', timeout: 8000 },
success: { title: '操作成功', variant: 'success', timeout: 4000 },
warning: { title: '请注意', variant: 'warning', timeout: 8000 },
};
// Dismissing feedback must not clear the request's failure state or the form draft.
export default function useFeedbackState(tone = 'error') {
const [message, setMessage] = useState('');
const toastKeyRef = useRef(null);
const isMountedRef = useRef(true);
const updateMessage = useCallback((nextMessage) => {
if (!isMountedRef.current) return;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
setMessage(nextMessage);
if (!nextMessage) return;
const { title, variant, timeout } = FEEDBACK_OPTIONS[tone] || FEEDBACK_OPTIONS.error;
// Every attempt reports feedback, including identical errors on a retry.
toastKeyRef.current = toast(title, { description: nextMessage, variant, timeout });
}, [tone]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
};
}, []);
return [message, updateMessage];
}
import useFeedbackState from '@/hooks/useFeedbackState';
import xyLogoRed from '@/assets/xy-logo-red.png'; import xyLogoRed from '@/assets/xy-logo-red.png';
import xyLogoWhite from '@/assets/xy-logo-white.png'; import xyLogoWhite from '@/assets/xy-logo-white.png';
import { import {
...@@ -7,7 +8,7 @@ import authService from '@/services/authService'; ...@@ -7,7 +8,7 @@ import authService from '@/services/authService';
import { normalizeLoginRedirect } from '@/utils/authRedirect'; import { normalizeLoginRedirect } from '@/utils/authRedirect';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react'; import { Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi'; import { history, useSearchParams } from 'umi';
...@@ -26,7 +27,7 @@ export default function LoginPage() { ...@@ -26,7 +27,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null); const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const redirect = normalizeLoginRedirect(searchParams.get('redirect')); const redirect = normalizeLoginRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
...@@ -176,14 +177,6 @@ export default function LoginPage() { ...@@ -176,14 +177,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button <Button
fullWidth fullWidth
className="login-submit-button" className="login-submit-button"
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
DataTable, DataTable,
...@@ -5,8 +6,9 @@ import { ...@@ -5,8 +6,9 @@ import {
DataTableSecondaryText, DataTableSecondaryText,
DataTableText, DataTableText,
} from '@/components/DataTable'; } from '@/components/DataTable';
import { Alert, Spinner, Surface } from '@heroui/react'; import { Alert, Button, Spinner, Surface } from '@heroui/react';
import { loadSubject, queryRecords } from '@/services/DataService'; import { loadSubject, queryRecords } from '@/services/DataService';
import SubjectValue from './SubjectValue';
import { import {
formatDateText, formatDateText,
formatFieldValue, formatFieldValue,
...@@ -92,12 +94,16 @@ function formatReferenceCellValue(value, field) { ...@@ -92,12 +94,16 @@ function formatReferenceCellValue(value, field) {
} }
function CellValue({ value, field }) { function CellValue({ value, field }) {
if (field?.fieldDef?.type === 'subject') {
return <SubjectValue value={value} fieldDef={field.fieldDef} compact />;
}
const text = formatReferenceCellValue(value, field); const text = formatReferenceCellValue(value, field);
if (!text) { if (!text) {
return <span className="text-muted">-</span>; return <span className="text-muted">-</span>;
} }
if (field?.fieldDef?.type === 'object' || field?.fieldDef?.type === 'form' || field?.fieldDef?.type === 'subject') { if (field?.fieldDef?.type === 'object' || field?.fieldDef?.type === 'form') {
return ( return (
<DataTableSecondaryText text={text} showTooltip /> <DataTableSecondaryText text={text} showTooltip />
); );
...@@ -130,7 +136,8 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -130,7 +136,8 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -195,7 +202,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -195,7 +202,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [pageSize, populatedRecords, referenceIds, subjectName]); }, [pageSize, populatedRecords, referenceIds, subjectName, retryKey, setError]);
if (referenceIds.length === 0) { if (referenceIds.length === 0) {
return ( return (
...@@ -232,14 +239,13 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -232,14 +239,13 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{error ? ( {error ? (
<Alert status="warning"> <div className="space-y-2">
<Alert.Content> <DataTableEmptyState title="关联明细加载失败,详细错误见操作提示" />
<Alert.Description>{error}</Alert.Description> <Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>重新加载关联明细</Button>
</Alert.Content> </div>
</Alert>
) : null} ) : null}
<ReferenceRecordsFrame action={action} count={records.length}> <ReferenceRecordsFrame action={action} count={records.length}>
{records.length === 0 ? ( {records.length === 0 && error ? null : records.length === 0 ? (
<DataTableEmptyState title="暂无可展示的关联明细" /> <DataTableEmptyState title="暂无可展示的关联明细" />
) : ( ) : (
<DataTable <DataTable
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useState } from 'react';
import { Accordion, Button, Chip, Spinner } from '@heroui/react';
import XinyuanModal from '@/components/XinyuanModal';
import { DataTableEmptyState } from '@/components/DataTable';
import { loadSubjectValue } from '@/services/SubjectValueService';
import {
formatFieldValue,
getFieldDisplayLabel,
resolveFieldValue,
toArray,
} from '@/utils/subjectView';
import { formatSubjectSummary, hasSubjectDisplayValue } from '@/utils/subjectValue';
function SubjectFieldValue({ fieldDef, value }) {
if (fieldDef.type === 'subject') return <SubjectValue fieldDef={fieldDef} value={value} />;
const text = formatFieldValue(value, fieldDef);
if (fieldDef.type === 'select' || fieldDef.type === 'user') {
return (
<div className="flex flex-wrap gap-1.5">
{toArray(value).map((entry, index) => (
<Chip key={index} size="sm" variant="secondary">
{formatFieldValue(entry, fieldDef)}
</Chip>
))}
</div>
);
}
return <span className="break-words whitespace-pre-wrap text-foreground">{text || '暂无内容'}</span>;
}
function SubjectValueContent({ fieldDef, value }) {
const [state, setState] = useState({ loading: true });
const [attempt, setAttempt] = useState(0);
const [, setError] = useFeedbackState('error');
const [, setWarning] = useFeedbackState('warning');
useEffect(() => {
let cancelled = false;
setState({ loading: true });
setError('');
setWarning('');
loadSubjectValue(fieldDef, value)
.then((result) => {
if (!cancelled) {
setState({ ...result, loading: false });
setWarning(result.warning || '');
}
})
.catch((error) => {
if (!cancelled) {
setState({ loading: false, error: true });
setError(error?.message || '明细加载失败,请稍后重试。');
}
});
return () => { cancelled = true; };
}, [fieldDef, value, attempt, setError, setWarning]);
if (state.loading) {
return (
<div className="flex min-h-24 items-center justify-center gap-2 text-sm text-muted" role="status">
<Spinner size="sm" />正在加载明细
</div>
);
}
if (state.error) {
return (
<div className="space-y-2">
<DataTableEmptyState title="明细加载失败,详细错误见操作提示" />
<Button size="sm" variant="secondary" onPress={() => setAttempt((current) => current + 1)}>重试</Button>
</div>
);
}
const fields = state.subject.fields;
const labelForItem = (item, index) => {
const departmentField = fields.find((field) => field.name === 'business_unit');
return formatFieldValue(item.business_unit, departmentField)
|| item.title || `明细 ${index + 1}`;
};
return (
<div className="min-w-0 space-y-3">
<Accordion allowsMultipleExpanded defaultExpandedKeys={new Set(['0'])} variant="surface">
{state.records.map((item, index) => (
<Accordion.Item key={index} id={String(index)}>
<Accordion.Heading>
<Accordion.Trigger>
<span className="min-w-0 break-words">{labelForItem(item, index)}</span>
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body>
<div className="grid min-w-0 grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
{fields.map((field) => {
const fieldValue = resolveFieldValue(item, field.name, field);
if (!hasSubjectDisplayValue(fieldValue)) return null;
const isFullWidth = ['long_text', 'rich_text', 'subject', 'object', 'form'].includes(field.type);
return (
<div key={field.name} className={`min-w-0 ${isFullWidth ? 'md:col-span-2' : ''}`}>
<div className="mb-1.5 text-sm text-muted">{getFieldDisplayLabel(field)}</div>
<SubjectFieldValue fieldDef={field} value={fieldValue} />
</div>
);
})}
</div>
</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</div>
);
}
export default function SubjectValue({ fieldDef, value, compact = false }) {
const [open, setOpen] = useState(false);
const summary = formatSubjectSummary(value, fieldDef);
if (!summary) return compact
? <span className="text-muted">-</span>
: <DataTableEmptyState title="暂无明细" />;
if (!compact) return <SubjectValueContent fieldDef={fieldDef} value={value} />;
return (
<div className="flex min-w-0 flex-col items-start gap-1.5">
<span className="line-clamp-2 text-sm text-muted">{summary}</span>
<Button size="sm" variant="tertiary" aria-label={`查看${getFieldDisplayLabel(fieldDef)}`} onPress={() => setOpen(true)}>查看明细</Button>
<XinyuanModal
isOpen={open}
onOpenChange={setOpen}
title={getFieldDisplayLabel(fieldDef)}
description={summary}
size="4xl"
footer={<Button variant="secondary" onPress={() => setOpen(false)}>关闭</Button>}
>
{open ? <SubjectValueContent fieldDef={fieldDef} value={value} /> : null}
</XinyuanModal>
</div>
);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
...@@ -17,6 +18,7 @@ import ReferenceSearchSelect from '@/components/ReferenceSearchSelect'; ...@@ -17,6 +18,7 @@ import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import IntegrityRiskMaterialField from '@/pages/integrity_risk_prevention/IntegrityRiskMaterialField'; import IntegrityRiskMaterialField from '@/pages/integrity_risk_prevention/IntegrityRiskMaterialField';
import ReferenceRecordsTable from './ReferenceRecordsTable'; import ReferenceRecordsTable from './ReferenceRecordsTable';
import SubjectValue from './SubjectValue';
import { import {
dateInputValue, dateInputValue,
formatFieldValue, formatFieldValue,
...@@ -95,6 +97,10 @@ function getTextPlaceholder(placeholder, fieldDef) { ...@@ -95,6 +97,10 @@ function getTextPlaceholder(placeholder, fieldDef) {
} }
function FieldValue({ value, fieldDef }) { function FieldValue({ value, fieldDef }) {
if (fieldDef?.type === 'subject') {
return <SubjectValue value={value} fieldDef={fieldDef} />;
}
if (isEmptyFieldValue(value)) { if (isEmptyFieldValue(value)) {
return <span className="text-sm text-muted">暂无内容</span>; return <span className="text-sm text-muted">暂无内容</span>;
} }
...@@ -146,7 +152,7 @@ function FieldValue({ value, fieldDef }) { ...@@ -146,7 +152,7 @@ function FieldValue({ value, fieldDef }) {
); );
} }
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') { if (fieldDef?.type === 'object' || fieldDef?.type === 'form') {
return ( return (
<Card variant="tertiary"> <Card variant="tertiary">
<Card.Content> <Card.Content>
...@@ -227,6 +233,7 @@ function ControlledFieldEditor({ ...@@ -227,6 +233,7 @@ function ControlledFieldEditor({
onValidationChange, onValidationChange,
}) { }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const fieldType = fieldDef?.type || 'text'; const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType); const isDateField = isDateFieldType(fieldType);
...@@ -239,6 +246,7 @@ function ControlledFieldEditor({ ...@@ -239,6 +246,7 @@ function ControlledFieldEditor({
setUploading(true); setUploading(true);
setError(''); setError('');
setRequestError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId); onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
try { try {
...@@ -246,7 +254,7 @@ function ControlledFieldEditor({ ...@@ -246,7 +254,7 @@ function ControlledFieldEditor({
const fileValue = uploaded?.file || uploaded?.data || uploaded; const fileValue = uploaded?.file || uploaded?.data || uploaded;
onChange?.(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue); onChange?.(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue);
} catch (uploadError) { } catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。'); setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally { } finally {
setUploading(false); setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId); onValidationChange?.(fieldPath, '', editSessionId);
...@@ -432,6 +440,7 @@ export default function TailwindEditableField({ ...@@ -432,6 +440,7 @@ export default function TailwindEditableField({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value); const [draft, setDraft] = useState(value);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState('error');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
useEffect(() => { useEffect(() => {
...@@ -474,6 +483,7 @@ export default function TailwindEditableField({ ...@@ -474,6 +483,7 @@ export default function TailwindEditableField({
const save = async (nextValue = draft) => { const save = async (nextValue = draft) => {
setError(''); setError('');
setRequestError('');
if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) { if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) {
try { try {
...@@ -488,7 +498,7 @@ export default function TailwindEditableField({ ...@@ -488,7 +498,7 @@ export default function TailwindEditableField({
await onSave(asSaveValue(nextValue, fieldDef)); await onSave(asSaveValue(nextValue, fieldDef));
setEditing(false); setEditing(false);
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。'); setRequestError(saveError?.message || '保存失败,请稍后重试。');
} }
}; };
...@@ -501,6 +511,7 @@ export default function TailwindEditableField({ ...@@ -501,6 +511,7 @@ export default function TailwindEditableField({
const cancel = () => { const cancel = () => {
setDraft(value); setDraft(value);
setError(''); setError('');
setRequestError('');
setEditing(false); setEditing(false);
}; };
...@@ -512,6 +523,7 @@ export default function TailwindEditableField({ ...@@ -512,6 +523,7 @@ export default function TailwindEditableField({
setUploading(true); setUploading(true);
setError(''); setError('');
setRequestError('');
try { try {
const uploaded = await uploadFile(file); const uploaded = await uploadFile(file);
...@@ -521,7 +533,7 @@ export default function TailwindEditableField({ ...@@ -521,7 +533,7 @@ export default function TailwindEditableField({
await onSave(nextValue); await onSave(nextValue);
setEditing(false); setEditing(false);
} catch (uploadError) { } catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。'); setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally { } finally {
setUploading(false); setUploading(false);
} }
...@@ -529,6 +541,7 @@ export default function TailwindEditableField({ ...@@ -529,6 +541,7 @@ export default function TailwindEditableField({
const handleRemoveFile = async (indexToRemove) => { const handleRemoveFile = async (indexToRemove) => {
setError(''); setError('');
setRequestError('');
const nextValue = fieldDef?.multiple const nextValue = fieldDef?.multiple
? toArray(draft).filter((_, index) => index !== indexToRemove) ? toArray(draft).filter((_, index) => index !== indexToRemove)
...@@ -542,7 +555,7 @@ export default function TailwindEditableField({ ...@@ -542,7 +555,7 @@ export default function TailwindEditableField({
} }
} catch (saveError) { } catch (saveError) {
setDraft(draft); setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。'); setRequestError(saveError?.message || '删除附件失败,请稍后重试。');
} }
}; };
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
getSubjectView, getSubjectView,
...@@ -75,7 +76,7 @@ export function useSubjectRecord( ...@@ -75,7 +76,7 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]); const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
useEffect(() => { useEffect(() => {
...@@ -85,6 +86,8 @@ export function useSubjectRecord( ...@@ -85,6 +86,8 @@ export function useSubjectRecord(
const loadAll = async () => { const loadAll = async () => {
if (!recordName) { if (!recordName) {
setError('');
setLoading(false);
setRemoteRecord(null); setRemoteRecord(null);
setEditableRecord(null); setEditableRecord(null);
setSubject(null); setSubject(null);
...@@ -144,7 +147,7 @@ export function useSubjectRecord( ...@@ -144,7 +147,7 @@ export function useSubjectRecord(
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [recordName, refreshKey, subjectName]); }, [recordName, refreshKey, subjectName, setError]);
const handleFieldsUpdate = async (changes = []) => { const handleFieldsUpdate = async (changes = []) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) { if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons'; import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { Alert, Button, Description, Modal, Spinner, Tabs, Tooltip } from '@heroui/react'; import { Alert, Button, Description, Modal, Spinner, Tabs, Tooltip } from '@heroui/react';
import React, { import React, {
...@@ -71,7 +73,8 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -71,7 +73,8 @@ const RecordDetailDrawer = (props, ref) => {
const [draftRecord, setDraftRecord] = useState(null); const [draftRecord, setDraftRecord] = useState(null);
const [dirtyFields, setDirtyFields] = useState({}); const [dirtyFields, setDirtyFields] = useState({});
const [validationErrors, setValidationErrors] = useState({}); const [validationErrors, setValidationErrors] = useState({});
const [saveError, setSaveError] = useState(''); const [, setSaveError] = useFeedbackState('error');
const [, setSaveSuccess] = useFeedbackState('success');
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
const pendingLocalMaterialsRef = useRef({}); const pendingLocalMaterialsRef = useRef({});
...@@ -85,7 +88,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -85,7 +88,7 @@ const RecordDetailDrawer = (props, ref) => {
isFieldDisabled, isFieldDisabled,
loading, loading,
} = useSubjectRecord( } = useSubjectRecord(
recordName, open ? recordName : null,
refreshKey, refreshKey,
readonly, readonly,
subjectName, subjectName,
...@@ -104,6 +107,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -104,6 +107,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1; editSessionRef.current += 1;
}, [open, recordName]); }, [open, recordName]);
...@@ -221,6 +225,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -221,6 +225,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
}; };
...@@ -237,6 +242,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -237,6 +242,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
setIsEditing(true); setIsEditing(true);
}; };
...@@ -319,6 +325,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -319,6 +325,7 @@ const RecordDetailDrawer = (props, ref) => {
setIsSaving(true); setIsSaving(true);
setSaveError(''); setSaveError('');
setSaveSuccess('');
try { try {
const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current) const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current)
...@@ -349,6 +356,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -349,6 +356,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors({}); setValidationErrors({});
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
onDataChange?.(true); onDataChange?.(true);
setSaveSuccess('详情已保存。');
} catch (saveFailure) { } catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。'); setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
} finally { } finally {
...@@ -452,18 +460,17 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -452,18 +460,17 @@ const RecordDetailDrawer = (props, ref) => {
<Spinner size="sm" /> <Spinner size="sm" />
</div> </div>
) : error ? ( ) : error ? (
<Alert status="danger"> <EmptyState className="min-h-48" size="md">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content> <EmptyState.Header>
</Alert> <EmptyState.Title>详情加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => setRefreshKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
) : ( ) : (
<div> <div>
{saveError ? (
<Alert className="mb-4" status="danger">
<Alert.Content>
<Alert.Description>{saveError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{isRecordLocked ? ( {isRecordLocked ? (
<Alert status="warning"> <Alert status="warning">
<Alert.Content> <Alert.Content>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/xy-logo-red.png'; import xinyuanLogo from '@/assets/xy-logo-red.png';
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
...@@ -6,12 +7,11 @@ import { ...@@ -6,12 +7,11 @@ import {
} from '@/config/application'; } from '@/config/application';
import { getApplications } from '@/services/DataService'; import { getApplications } from '@/services/DataService';
import { EmptyStateSurface } from '@/components/DataTable'; import { EmptyStateSurface } from '@/components/DataTable';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons'; import { MagnifyingGlassIcon } from '@/components/AppIcons';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card'; import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group'; import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import { import {
Alert,
Avatar, Avatar,
Chip, Chip,
Modal, Modal,
...@@ -313,7 +313,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -313,7 +313,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState( const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle', fallbackApplications.length > 0 ? 'success' : 'idle',
); );
const [errorMessage, setErrorMessage] = useState(''); const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState(''); const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => { const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME; return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
...@@ -353,6 +353,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -353,6 +353,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setApplicationSearch(''); setApplicationSearch('');
setErrorMessage('');
return; return;
} }
...@@ -393,7 +394,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -393,7 +394,7 @@ export default function ApplicationSwitcherDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [fallbackApplications, open]); }, [fallbackApplications, open, setErrorMessage]);
return ( return (
<Modal.Root> <Modal.Root>
...@@ -441,13 +442,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -441,13 +442,6 @@ export default function ApplicationSwitcherDialog({
offset={12} offset={12}
size={24} size={24}
> >
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? ( {status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? ( ) : filteredApplicationSections.length > 0 ? (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Alert, Button } from '@heroui/react'; import { Alert, Button } from '@heroui/react';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
...@@ -93,8 +94,9 @@ export default function ControlMeasureFormPage() { ...@@ -93,8 +94,9 @@ export default function ControlMeasureFormPage() {
const [workspace, setWorkspace] = useState(null); const [workspace, setWorkspace] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(''); const [actionLoading, setActionLoading] = useState('');
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [success, setSuccess] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [, setSuccess] = useFeedbackState('success');
const [rejectType, setRejectType] = useState(''); const [rejectType, setRejectType] = useState('');
const [withdrawOpen, setWithdrawOpen] = useState(false); const [withdrawOpen, setWithdrawOpen] = useState(false);
...@@ -105,22 +107,25 @@ export default function ControlMeasureFormPage() { ...@@ -105,22 +107,25 @@ export default function ControlMeasureFormPage() {
const loadPage = useCallback(async () => { const loadPage = useCallback(async () => {
if (!messageId) { if (!messageId) {
setLoadFailed(true);
setLoading(false); setLoading(false);
setWorkspace(null); setWorkspace(null);
setError('链接缺少流程记录 id,无法加载廉洁风险防控填写表。'); setError('链接缺少流程记录 id,无法加载廉洁风险防控填写表。');
return; return;
} }
setLoading(true); setLoading(true);
setLoadFailed(false);
setError(''); setError('');
try { try {
setWorkspace(await loadControlMeasureWorkspace(messageId)); setWorkspace(await loadControlMeasureWorkspace(messageId));
} catch (requestError) { } catch (requestError) {
setLoadFailed(true);
setWorkspace(null); setWorkspace(null);
setError(requestError?.message || '廉洁风险防控填写表加载失败。'); setError(requestError?.message || '廉洁风险防控填写表加载失败。');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [messageId]); }, [messageId, setError]);
useEffect(() => { useEffect(() => {
document.title = '廉洁风险防控填写表'; document.title = '廉洁风险防控填写表';
...@@ -329,8 +334,8 @@ export default function ControlMeasureFormPage() { ...@@ -329,8 +334,8 @@ export default function ControlMeasureFormPage() {
}} }}
states={{ states={{
loading, loading,
error, loadFailed,
success, onRetry: loadPage,
loadingTitle: '正在加载廉洁风险防控填写表', loadingTitle: '正在加载廉洁风险防控填写表',
loadingDescription: '正在读取流程、对象结构、视图配置和关联记录。', loadingDescription: '正在读取流程、对象结构、视图配置和关联记录。',
emptyTitle: '当前流程暂无关联记录', emptyTitle: '当前流程暂无关联记录',
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Chip } from '@heroui/react'; import { Button, Chip } from '@heroui/react';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
...@@ -40,8 +41,8 @@ function statusColor(status) { ...@@ -40,8 +41,8 @@ function statusColor(status) {
export default function DepartmentControlMeasureFlow({ workspace, userInfo, onReload }) { export default function DepartmentControlMeasureFlow({ workspace, userInfo, onReload }) {
const [activeItem, setActiveItem] = useState(null); const [activeItem, setActiveItem] = useState(null);
const [actionKey, setActionKey] = useState(''); const [actionKey, setActionKey] = useState('');
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const [success, setSuccess] = useState(''); const [, setSuccess] = useFeedbackState('success');
const [rejectContext, setRejectContext] = useState(null); const [rejectContext, setRejectContext] = useState(null);
const [withdrawContext, setWithdrawContext] = useState(null); const [withdrawContext, setWithdrawContext] = useState(null);
const [userLabels, setUserLabels] = useState({}); const [userLabels, setUserLabels] = useState({});
...@@ -240,8 +241,6 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe ...@@ -240,8 +241,6 @@ export default function DepartmentControlMeasureFlow({ workspace, userInfo, onRe
rowActions={rowActions} rowActions={rowActions}
renderCell={renderCell} renderCell={renderCell}
states={{ states={{
error,
success,
emptyTitle: '当前流程暂无部门明细', emptyTitle: '当前流程暂无部门明细',
emptyDescription: '请检查流程链接,或联系发起人重新推送。', emptyDescription: '请检查流程链接,或联系发起人重新推送。',
}} }}
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { ArrowPathIcon, Delete } from '@/components/AppIcons'; import { ArrowPathIcon, Delete } from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
...@@ -5,7 +6,7 @@ import { ...@@ -5,7 +6,7 @@ import {
queryIntegrityRiskMaterials, queryIntegrityRiskMaterials,
uploadIntegrityRiskFile, uploadIntegrityRiskFile,
} from './IntegrityRiskMaterialService'; } from './IntegrityRiskMaterialService';
import { Alert, Button, Label, Tabs, Tooltip } from '@heroui/react'; import { Button, Label, Tabs, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
const MATERIAL_CENTER_TAB = 'material-center'; const MATERIAL_CENTER_TAB = 'material-center';
...@@ -50,10 +51,10 @@ export default function IntegrityRiskMaterialField({ ...@@ -50,10 +51,10 @@ export default function IntegrityRiskMaterialField({
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null); const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false); const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState(''); const [materialsError, setMaterialsError] = useFeedbackState('error');
const [retryKey, setRetryKey] = useState(0); const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(''); const [, setUploadError] = useFeedbackState('error');
const [localFileKeys, setLocalFileKeys] = useState(() => new Set()); const [localFileKeys, setLocalFileKeys] = useState(() => new Set());
const fileValues = useMemo( const fileValues = useMemo(
() => (multiple ? toFileArray(value) : toFileArray(value).slice(0, 1)), () => (multiple ? toFileArray(value) : toFileArray(value).slice(0, 1)),
...@@ -68,7 +69,7 @@ export default function IntegrityRiskMaterialField({ ...@@ -68,7 +69,7 @@ export default function IntegrityRiskMaterialField({
setMaterialsError(''); setMaterialsError('');
setUploadError(''); setUploadError('');
setLocalFileKeys(new Set()); setLocalFileKeys(new Set());
}, [editSessionId]); }, [editSessionId, setMaterialsError, setUploadError]);
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => setDebouncedKeyword(keyword), 300); const timeoutId = window.setTimeout(() => setDebouncedKeyword(keyword), 300);
...@@ -90,7 +91,7 @@ export default function IntegrityRiskMaterialField({ ...@@ -90,7 +91,7 @@ export default function IntegrityRiskMaterialField({
}) })
.finally(() => { if (!cancelled) setMaterialsLoading(false); }); .finally(() => { if (!cancelled) setMaterialsLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [activeTab, debouncedKeyword, retryKey]); }, [activeTab, debouncedKeyword, retryKey, setMaterialsError]);
const materialOptions = useMemo(() => materials.map((material) => ({ const materialOptions = useMemo(() => materials.map((material) => ({
...material, ...material,
...@@ -256,12 +257,9 @@ export default function IntegrityRiskMaterialField({ ...@@ -256,12 +257,9 @@ export default function IntegrityRiskMaterialField({
)} )}
/> />
{materialsError ? ( {materialsError ? (
<Alert status="danger"> <Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}>
<Alert.Content><Alert.Description>{materialsError}</Alert.Description></Alert.Content> <ArrowPathIcon size={14} />重新加载材料
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}>
<ArrowPathIcon size={14} />重试
</Button> </Button>
</Alert>
) : null} ) : null}
</div> </div>
</Tabs.Panel> </Tabs.Panel>
...@@ -276,9 +274,6 @@ export default function IntegrityRiskMaterialField({ ...@@ -276,9 +274,6 @@ export default function IntegrityRiskMaterialField({
triggerLabel={uploading ? '上传中...' : '选择本地文件'} triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload} onFilesChange={handleLocalUpload}
/> />
{uploadError ? (
<Alert status="danger"><Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content></Alert>
) : null}
</div> </div>
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
......
...@@ -34,6 +34,14 @@ ...@@ -34,6 +34,14 @@
4. 新建、复制和软删除沿用工作台既有行为;主对象列表行内不提供独立编辑按钮,业务字段写入 4. 新建、复制和软删除沿用工作台既有行为;主对象列表行内不提供独立编辑按钮,业务字段写入
`metadata`。过程表展开后的部门子项行提供“编辑子项”或“查看子项”操作,不复用主对象详情编辑。 `metadata`。过程表展开后的部门子项行提供“编辑子项”或“查看子项”操作,不复用主对象详情编辑。
5. 归档页为只读,不提供新建、编辑、复制或删除。 5. 归档页为只读,不提供新建、编辑、复制或删除。
从“往期材料归档”打开批次详情后,过程表历史快照的“部门填报明细”单元格显示明细数量
和责任单位摘要;点击“查看明细”打开只读弹窗,按部门折叠展示字段,默认展开第一条。
详情中直接展示该嵌套字段时,同样按部门展示。字段标签、选择项及公式来自运行时加载的
`risk_prevention_control_measures_table_department_item` 定义,空字段不展示,人员和制度引用
按快照保存的 ID 补查显示名称;补查失败时显示提示并保留原 ID。所有业务值、审批状态和
时间均取自归档嵌套快照,不从当前过程表或部门记录重新获取;查看不改变归档及流程状态。
支持嵌套对象/数组以及历史 JSON 字符串值;无部门快照时表格显示“-”、详情显示“暂无明细”,结构配置缺失、
数据格式异常或加载失败时显示错误并提供重试。
6. 页面请求必须展示加载、成功和错误状态;删除与替换导入必须提供确认。删除确认弹窗 6. 页面请求必须展示加载、成功和错误状态;删除与替换导入必须提供确认。删除确认弹窗
仅展示选中记录数量和软删除说明,不展示记录标题列表。 仅展示选中记录数量和软删除说明,不展示记录标题列表。
7. Grid View 的 `settings.shortTitle === true` 时隐藏列表 `title` 展示列及新建/编辑弹窗中的根级 7. Grid View 的 `settings.shortTitle === true` 时隐藏列表 `title` 展示列及新建/编辑弹窗中的根级
...@@ -120,7 +128,8 @@ ...@@ -120,7 +128,8 @@
- 材料中心查询 `am_material` 最近 20 条,按材料标题或文件名远程搜索;单值替换,多值追加 - 材料中心查询 `am_material` 最近 20 条,按材料标题或文件名远程搜索;单值替换,多值追加
并按 URL 去重,清空业务附件不删除材料主档。 并按 URL 去重,清空业务附件不删除材料主档。
- 本地文件先调用 `/upload` 进入详情草稿。取消编辑不建档;保存详情时,先逐文件创建 - 本地文件先调用 `/upload` 进入详情草稿。取消编辑不建档;保存详情时,先逐文件创建
`am_material`,写入 `source_app = integrity_risk_prevention`、当前用户和 ISO 上传时间, `am_material`,写入 `metadata.source_app = 廉洁风险防控`(复用当前项目中文名称
`APPLICATION_DISPLAY_NAME`)、当前用户和 ISO 上传时间,
全部成功后才更新业务记录。部分失败保留草稿与已建档标记,重试不重复建档。 全部成功后才更新业务记录。部分失败保留草稿与已建档标记,重试不重复建档。
### 2.3 通知消息 ### 2.3 通知消息
...@@ -193,6 +202,17 @@ Grid 视图配置示例(列举的是配置片段,修改时应保留完整原 ...@@ -193,6 +202,17 @@ Grid 视图配置示例(列举的是配置片段,修改时应保留完整原
过程表仍按现有规则隐藏 `department_control_items` 主表列,部门展开行中的引用与选择字段应用相同列配置。首个可见字段配置为数量或标签时仍可点击进入详情。详情抽屉及归档嵌套明细弹窗沿用已有展示逻辑。 过程表仍按现有规则隐藏 `department_control_items` 主表列,部门展开行中的引用与选择字段应用相同列配置。首个可见字段配置为数量或标签时仍可点击进入详情。详情抽屉及归档嵌套明细弹窗沿用已有展示逻辑。
### 2.5 操作提示与失败重试
- 功能入口:登录与登录状态验证、站点配置、应用切换、过程表/展示表/措施库/归档列表、新建与详情编辑、部门子项编辑、附件上传与材料搜索、填报审批流程页及通知中心。
- 查询、保存、新建、复制、删除、推送、导入、导出、归档、附件上传、送审、撤回及审批的成功/失败反馈统一通过应用根部 HeroUI Toast 展示;站点配置、应用列表和表格显示配置降级使用警告提示。浮层位于视口顶部,层级高于业务 Modal、确认弹窗和移动侧栏,弹窗打开或页面滚动时仍可见、可关闭,不抢走表单焦点。
- 成功提示默认 4 秒、错误与警告默认 8 秒后消失;悬停或键盘聚焦暂停计时;当前提示右侧的关闭图标始终可见,使用 HeroUI 默认小号透明按钮,点击或键盘激活均通过 Toast 的 `close` slot 关闭。后排堆叠提示不显示关闭按钮,避免误关闭。相同错误再次发生时重新提示,同一来源的新提示替换旧提示;清空反馈或卸载对应页面/字段时清理该来源提示。
- 关闭提示仅影响展示,不将失败改为成功,不修改草稿、附件、选中记录、筛选条件、审批状态或 `metadata`,也不重新发起写入。必填、JSON 等表单校验继续在控件附近展示;锁定说明、驳回和撤回确认说明保留在原位置。
- 详情弹窗点击“保存”后,只有本次待建档材料和 `updateRecord` 全部成功,才退出编辑并弹出“详情已保存”成功提示,弹窗继续展示已保存内容;任一接口失败仅显示错误并保留草稿。重新开始编辑、再次保存、关闭弹窗或切换记录时清理上一条成功提示;无字段修改、校验未通过或正在保存时不提交、不弹出成功提示。
- 列表、详情、登录验证、通知和填报流程加载失败时,保留失败空状态和“重新加载/重试”入口,详细原因由浮层展示,避免误显示“暂无数据”。重试只读取当前页面资源,不再次执行保存、推送、审批或归档;通知保留当前分类、已读条件、关键词和页码。
- 人员与引用选择器查询失败时显示浮层及字段级加载错误;关联明细、嵌套 Subject 结构、归档部门快照和材料查询保留读取重试入口。快照人员/引用补查降级通过警告提示并保留原 ID。保存或上传失败保留当前编辑内容,批量附件部分失败保留已成功上传的附件,可修正后再次提交。
- 本次只调整反馈展示,继续使用已有 Record、Subject/View、用户、应用、通知及文件服务接口;业务字段、引用 ID、日期格式和状态流转协议保持原约束。
## 3. 过程表操作 ## 3. 过程表操作
### 3.0 推送链接与登录回跳 ### 3.0 推送链接与登录回跳
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable'; import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state'; import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Card, Card,
...@@ -134,7 +134,7 @@ export default function NotificationListPage() { ...@@ -134,7 +134,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0); const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -166,7 +166,7 @@ export default function NotificationListPage() { ...@@ -166,7 +166,7 @@ export default function NotificationListPage() {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
}); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [category, keyword, page, readStatus, reloadKey]); }, [category, keyword, page, readStatus, reloadKey, setError]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -181,11 +181,11 @@ export default function NotificationListPage() { ...@@ -181,11 +181,11 @@ export default function NotificationListPage() {
setBusyKey(key); setBusyKey(key);
try { try {
await operation(); await operation();
toast.success(successMessage, { timeout: 3000 }); toast.success(successMessage, { timeout: 4000 });
refresh(); refresh();
return true; return true;
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 8000 });
return false; return false;
} finally { } finally {
setBusyKey(''); setBusyKey('');
...@@ -205,7 +205,7 @@ export default function NotificationListPage() { ...@@ -205,7 +205,7 @@ export default function NotificationListPage() {
} }
refresh(); refresh();
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 }); toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 8000 });
} finally { } finally {
setBusyKey(''); setBusyKey('');
} }
...@@ -257,9 +257,15 @@ export default function NotificationListPage() { ...@@ -257,9 +257,15 @@ export default function NotificationListPage() {
</div> </div>
{error ? ( {error ? (
<Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content> <EmptyState className="min-h-48" size="md">
<Button size="sm" variant="ghost" onPress={refresh}>重试</Button> <EmptyState.Header>
</Alert> <EmptyState.Title>通知加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={refresh}>重试</Button>
</EmptyState.Content>
</EmptyState>
) : null} ) : null}
{loading && notifications.length === 0 ? ( {loading && notifications.length === 0 ? (
<div className="flex min-h-64 items-center justify-center" aria-label="通知加载中"><Spinner size="sm" /></div> <div className="flex min-h-64 items-center justify-center" aria-label="通知加载中"><Spinner size="sm" /></div>
......
import { loadSubject, queryRecords, queryUsers } from './DataService';
import {
getReferenceSubjectName,
isReferenceFieldType,
normalizeRecordId,
normalizeRecordLabel,
normalizeUserLabel,
toArray,
} from '@/utils/subjectView';
import { normalizeSubjectItems } from '@/utils/subjectValue';
const LOOKUP_PAGE_SIZE = 1000;
const SUBJECT_REQUESTS = new Map();
function loadNestedSubject(name) {
if (!SUBJECT_REQUESTS.has(name)) {
SUBJECT_REQUESTS.set(name, loadSubject(name).then((subject) => {
if (!Array.isArray(subject?.fields) || subject.fields.length === 0) {
throw new Error('子对象字段定义为空,无法展示明细。');
}
return subject;
}).catch((error) => {
SUBJECT_REQUESTS.delete(name);
throw error;
}));
}
return SUBJECT_REQUESTS.get(name);
}
// Only look up display names. The nested values always come from the snapshot,
// never from current department records or the original process table.
export async function loadSubjectValue(fieldDef, value) {
const items = normalizeSubjectItems(value);
const subjectName = getReferenceSubjectName(fieldDef);
if (!subjectName) throw new Error('明细字段缺少子对象配置,无法加载字段定义。');
const subject = await loadNestedSubject(subjectName);
const lookups = new Map();
subject.fields.forEach((field) => {
const isUser = field.type === 'user';
if (!isUser && !isReferenceFieldType(field.type)) return;
const target = isUser ? 'user' : getReferenceSubjectName(field);
if (!target) return;
const key = `${field.type}:${target}`;
if (!lookups.has(key)) lookups.set(key, { isUser, target, ids: new Set(), labels: {} });
const lookup = lookups.get(key);
items.forEach((item) => toArray(item[field.name]).forEach((entry) => {
const id = normalizeRecordId(entry);
const label = isUser ? normalizeUserLabel(entry) : normalizeRecordLabel(entry);
if (id && (!label || label === id)) lookup.ids.add(id);
}));
});
const requests = [];
lookups.forEach((lookup) => {
const ids = [...lookup.ids];
for (let offset = 0; offset < ids.length; offset += LOOKUP_PAGE_SIZE) {
const filter = { _id: { $in: ids.slice(offset, offset + LOOKUP_PAGE_SIZE) } };
requests.push((async () => {
const result = lookup.isUser
? await queryUsers(0, LOOKUP_PAGE_SIZE, filter)
: await queryRecords({
subject: lookup.target,
page: 0,
page_size: LOOKUP_PAGE_SIZE,
filter,
mode: 'basic',
});
(result?.[lookup.isUser ? 'users' : 'records'] || []).forEach((record) => {
lookup.labels[normalizeRecordId(record)] = record;
});
})());
}
});
const results = await Promise.allSettled(requests);
const unresolved = [...lookups.values()].some((lookup) =>
[...lookup.ids].some((id) => !lookup.labels[id]));
const records = items.map((item) => subject.fields.reduce((record, field) => {
const target = field.type === 'user' ? 'user' : getReferenceSubjectName(field);
const lookup = lookups.get(`${field.type}:${target}`);
if (!lookup || item[field.name] == null) return record;
const hydrate = (entry) => {
const id = normalizeRecordId(entry);
const label = lookup.isUser ? normalizeUserLabel(entry) : normalizeRecordLabel(entry);
return label && label !== id ? entry : lookup.labels[id] || entry;
};
record[field.name] = Array.isArray(item[field.name])
? item[field.name].map(hydrate)
: hydrate(item[field.name]);
return record;
}, { ...item }));
return {
subject,
records,
warning: unresolved || results.some((result) => result.status === 'rejected')
? '部分人员或引用名称未能加载,已保留原 ID 展示。'
: '',
};
}
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
@import "./design-system/theme.css"; @import "./design-system/theme.css";
@import "./design-system/tokens.css"; @import "./design-system/tokens.css";
@import "./design-system/shell.css"; @import "./design-system/shell.css";
@import "./design-system/feedback.css";
@import "./design-system/data-grid.css"; @import "./design-system/data-grid.css";
@source "./*.{js,jsx,ts,tsx}"; @source "./*.{js,jsx,ts,tsx}";
......
export function normalizeSubjectItems(value) {
let parsed = value;
if (typeof parsed === 'string') {
if (!parsed.trim()) return [];
try {
parsed = JSON.parse(parsed);
} catch {
throw new Error('明细格式不正确,无法展示。');
}
}
if (parsed === null || parsed === undefined) return [];
const items = Array.isArray(parsed) ? parsed : [parsed];
return items.map((item) => {
const source = item?.raw || item;
const metadata = source?.metadata ?? source;
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
throw new Error('明细中包含无效的子对象,无法展示。');
}
return metadata;
});
}
export function hasSubjectDisplayValue(value) {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.trim().length > 0;
if (Array.isArray(value)) return value.some(hasSubjectDisplayValue);
if (typeof value === 'object') return Object.values(value).some(hasSubjectDisplayValue);
return true;
}
export function formatSubjectSummary(value, fieldDef) {
try {
const items = normalizeSubjectItems(value);
if (items.length === 0) return '';
const isDepartment = fieldDef?.name === 'department_control_items';
const names = [...new Set(items.map((item) => {
const name = isDepartment ? item.business_unit : item.title;
return typeof name === 'object' && name !== null
? name.label || name.title || name.value || ''
: String(name ?? '').trim();
}).filter(Boolean))];
const summary = `${items.length} 条${isDepartment ? '部门明细' : '明细'}`;
return names.length
? `${summary} · ${names.slice(0, 3).join('、')}${names.length > 3 ? '等' : ''}`
: summary;
} catch {
return '明细格式异常';
}
}
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { AUTHORIZATION_APPLICATION_NAME } from '@/config/application'; import { AUTHORIZATION_APPLICATION_NAME } from '@/config/application';
import { formatSubjectSummary } from './subjectValue';
export function normalizeText(value) { export function normalizeText(value) {
return String(value ?? '').trim(); return String(value ?? '').trim();
...@@ -824,6 +825,10 @@ export function getFormValueSummaries(value, fallbackLabel = '表单') { ...@@ -824,6 +825,10 @@ export function getFormValueSummaries(value, fallbackLabel = '表单') {
} }
export function formatFieldValue(value, fieldDef) { export function formatFieldValue(value, fieldDef) {
if (fieldDef?.type === 'subject') {
return formatSubjectSummary(value, fieldDef);
}
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return ''; return '';
} }
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import authService from '@/services/authService'; import authService from '@/services/authService';
import { buildLoginUrl, LOGIN_PATH } from '@/utils/authRedirect'; import { buildLoginUrl, LOGIN_PATH } from '@/utils/authRedirect';
import { Alert, Button } from '@heroui/react'; import { Button } from '@heroui/react';
import { getUserInfo, userLogout } from '@/services/DataService'; import { getUserInfo, userLogout } from '@/services/DataService';
import { createContext, useEffect, useMemo, useState } from 'react'; import { createContext, useEffect, useMemo, useState } from 'react';
import { history, Outlet } from 'umi'; import { history, Outlet } from 'umi';
...@@ -20,7 +22,7 @@ function getStoredUser() { ...@@ -20,7 +22,7 @@ function getStoredUser() {
} }
export default function AuthWrapper() { export default function AuthWrapper() {
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useFeedbackState('error');
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [userInfo, setUserInfo] = useState(() => getStoredUser()); const [userInfo, setUserInfo] = useState(() => getStoredUser());
...@@ -69,7 +71,7 @@ export default function AuthWrapper() { ...@@ -69,7 +71,7 @@ export default function AuthWrapper() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [attempt]); }, [attempt, setLoadError]);
const value = useMemo(() => { const value = useMemo(() => {
if (!userInfo) { if (!userInfo) {
...@@ -104,8 +106,15 @@ export default function AuthWrapper() { ...@@ -104,8 +106,15 @@ export default function AuthWrapper() {
if (loadError) { if (loadError) {
return ( return (
<div className="mx-auto flex min-h-screen max-w-lg flex-col justify-center gap-4 p-6"> <div className="mx-auto flex min-h-screen max-w-lg flex-col justify-center gap-4 p-6">
<Alert status="danger"><Alert.Content><Alert.Description>{loadError}</Alert.Description></Alert.Content></Alert> <EmptyState size="md">
<EmptyState.Header>
<EmptyState.Title>登录状态验证失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新验证。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button onPress={() => setAttempt((value) => value + 1)}>重新验证登录状态</Button> <Button onPress={() => setAttempt((value) => value + 1)}>重新验证登录状态</Button>
</EmptyState.Content>
</EmptyState>
</div> </div>
); );
} }
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
...@@ -18,7 +19,7 @@ export default function SiteWrapper() { ...@@ -18,7 +19,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null); const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null); const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useFeedbackState('warning');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -54,7 +55,7 @@ export default function SiteWrapper() { ...@@ -54,7 +55,7 @@ export default function SiteWrapper() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [setLoadError]);
if (loading) { if (loading) {
return ( return (
......
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import test from 'node:test';
const require = createRequire(import.meta.url);
const asModule = (source) => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
const readSource = (path) => readFile(new URL(path, import.meta.url), 'utf8');
const valuesModule = asModule(await readSource('../src/utils/subjectValue.js'));
const configModule = asModule(await readSource('../src/config/application.js'));
const viewModule = asModule((await readSource('../src/utils/subjectView.js'))
.replace("'dayjs'", JSON.stringify(pathToFileURL(require.resolve('dayjs')).href))
.replace("'@/config/application'", JSON.stringify(configModule))
.replace("'./subjectValue'", JSON.stringify(valuesModule)));
const apiModule = asModule(`
export const calls = [];
export let failUsers = false;
export function reset(fail = false) { calls.length = 0; failUsers = fail; }
export async function loadSubject(name) {
calls.push({ kind: 'subject', name });
return { fields: [
{ name: 'business_unit', label: '责任单位', type: 'text' },
{ name: 'editor', label: '填写人', type: 'user' },
{ name: 'auditor', label: '审批人', type: 'user' },
{ name: 'file_name_and_document_number', type: 'reference', multiple: true, settings: { subject: 'internal_regulation' } },
] };
}
export async function queryUsers(page, pageSize, filter) {
calls.push({ kind: 'users', filter });
if (failUsers) throw new Error('lookup failed');
return { users: filter._id.$in.map((_id) => ({ _id, display_name: '用户 ' + _id })) };
}
export async function queryRecords(query) {
calls.push({ kind: 'records', query });
return { records: query.filter._id.$in.map((_id) => ({ _id, title: '制度 ' + _id })) };
}
`);
const serviceModule = asModule((await readSource('../src/services/SubjectValueService.js'))
.replace("'./DataService'", JSON.stringify(apiModule))
.replace("'@/utils/subjectView'", JSON.stringify(viewModule))
.replace("'@/utils/subjectValue'", JSON.stringify(valuesModule)));
const { normalizeSubjectItems, hasSubjectDisplayValue } = await import(valuesModule);
const { formatFieldValue, resolveFieldValue } = await import(viewModule);
const { loadSubjectValue } = await import(serviceModule);
const api = await import(apiModule);
const fieldDef = {
name: 'department_control_items', type: 'subject', multiple: true,
settings: { subject: 'risk_prevention_control_measures_table_department_item' },
};
test('department summaries support snapshots, metadata wrappers and legacy JSON strings', () => {
const items = [{ business_unit: '审计部', item_id: 'internal-id' }, { business_unit: '风险管理部' }];
for (const value of [items, JSON.stringify(items), items.map((metadata) => ({ metadata }))]) {
assert.equal(formatFieldValue(value, fieldDef), '2 条部门明细 · 审计部、风险管理部');
assert.deepEqual(normalizeSubjectItems(value), items);
}
assert.equal(formatFieldValue([], fieldDef), '');
assert.equal(formatFieldValue({ business_unit: '审计部' }, fieldDef), '1 条部门明细 · 审计部');
});
test('invalid snapshots report format errors while false and zero remain visible', () => {
assert.equal(formatFieldValue('[invalid', fieldDef), '明细格式异常');
assert.throws(() => normalizeSubjectItems('[invalid'), /格式不正确/);
assert.throws(() => normalizeSubjectItems([null]), /无效的子对象/);
assert.equal(hasSubjectDisplayValue(false), true);
assert.equal(hasSubjectDisplayValue(0), true);
assert.equal(hasSubjectDisplayValue([]), false);
});
test('name lookups use saved IDs and never replace archived business fields or mutate the snapshot', async () => {
api.reset();
const snapshot = [{ business_unit: '审计部', editor: 'user1', auditor: 'user1',
file_name_and_document_number: ['regulation1'], auditor_status: '已审批',
description_of_main_prevention_and_control_measures: '归档时的措施' }];
const original = structuredClone(snapshot);
const result = await loadSubjectValue(fieldDef, snapshot);
assert.equal(result.records[0].editor.display_name, '用户 user1');
assert.equal(result.records[0].file_name_and_document_number[0].title, '制度 regulation1');
assert.equal(result.records[0].description_of_main_prevention_and_control_measures, '归档时的措施');
assert.equal(result.records[0].auditor_status, '已审批');
assert.deepEqual(snapshot, original);
assert.deepEqual(api.calls.find((call) => call.kind === 'users').filter._id.$in, ['user1']);
assert.deepEqual(api.calls.filter((call) => call.kind === 'records').map((call) => call.query.subject), ['internal_regulation']);
assert.equal(result.warning, '');
});
test('failed user lookups retain original IDs and allow reference names to load independently', async () => {
api.reset(true);
const result = await loadSubjectValue(fieldDef, [{ editor: 'missing-user',
file_name_and_document_number: ['regulation1'], auditor_status: '已驳回' }]);
assert.equal(result.records[0].editor, 'missing-user');
assert.equal(result.records[0].auditor_status, '已驳回');
assert.equal(result.records[0].file_name_and_document_number[0].title, '制度 regulation1');
assert.match(result.warning, /保留原 ID/);
});
test('populated names are preserved and missing Subject configuration is reported', async () => {
api.reset();
const snapshot = [{ editor: { _id: 'user1', display_name: '归档用户' },
file_name_and_document_number: [{ _id: 'regulation1', title: '归档制度' }] }];
const result = await loadSubjectValue(fieldDef, snapshot);
assert.deepEqual(result.records, snapshot);
assert.equal(api.calls.filter((call) => call.kind !== 'subject').length, 0);
await assert.rejects(loadSubjectValue({ type: 'subject' }, snapshot), /缺少子对象配置/);
});
test('risk formulas calculate using nested snapshot fields', () => {
const formula = { type: 'formula', settings: {
formula: "SWITCH(PRODUCT([probability],[hazard_degree]), 1, '低', 2,'中', 3, '高', 4, '中', 6, '高', 9, '高')",
} };
assert.equal(resolveFieldValue({ probability: '2', hazard_degree: '3' }, 'risk_level', formula), '高');
});
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