Commit 69823071 authored by drigle's avatar drigle

feat: unify app feedback and configure table field display

parent 8d6af315
......@@ -266,8 +266,12 @@ Inter 与系统回退字体之间切换。
- 严谨业务界面不使用绿色系、粉色、玫红或洋红危险色;HeroUI 主色固定为鑫元基金品牌红
`#DA241A`,由 `--accent`/`--primary` 提供,Hover 使用 `#C91F17`,Pressed 使用 `#B51B14`。
`danger` / `danger-soft` 继续继承品牌红深色阶,不允许被 HeroUI 默认蓝色或其它主题色覆盖。
- 详情请求失败、详情动作失败和保存失败使用 HeroUI `toast.danger` 短提醒(约 4 秒自动消失),
不在弹窗内容流中渲染持续占位的错误 Alert;需要用户持续处理的锁定、字段校验等业务提示仍保留在上下文中。
- 详情请求、详情动作和保存反馈统一使用根部 `AppFeedback` 的 HeroUI Toast;成功默认 4 秒、错误和
警告默认 8 秒自动消失,不在弹窗内容流中渲染持续占位的接口反馈 Alert。详情保存全部成功后提示
“详情已保存。”,保留弹窗和已保存内容;锁定、字段校验等需要持续处理的业务提示保留在上下文中。
- Toast 关闭按钮使用 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`),通过官方 `slot="close"`
接入关闭行为,组合 `AppIcons.Close` 并位于正文右侧参与布局。前排提示的关闭图标始终可见,后排
堆叠提示隐藏关闭按钮;沿用官方 Hover/焦点样式,不添加带边框的放大关闭按钮、绝对定位或正文占位。
## 禁止事项
......
{
"name": "audit_project_manager",
"version": "1.0.2",
"version": "1.0.4",
"build": "abc",
"description": "审计项目管理",
"date": "2025-07-14 19:27:20",
......
import '@/tailwind.css';
import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
import {
APPLICATION_BASE_PATH,
APPLICATION_NAME,
......@@ -33,4 +35,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 { Toast } from '@heroui/react';
import { history } from 'umi';
import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar';
......@@ -32,7 +31,6 @@ export default function AppShell({
<div className="rc-app-page">{children}</div>
</div>
</Sidebar.Main>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
import { Alert, AlertDialog, Button, Checkbox, Chip, Input, Label, TextArea, TextField } from '@heroui/react';
import { AlertDialog, Button, Checkbox, Chip, Input, Label, TextArea, TextField } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import RecordWorkbench from '@/components/record-workbench';
import { DataTablePrimaryText } from '@/components/DataTable';
import FileUploadField from '@/components/FileUploadField';
......@@ -46,10 +47,34 @@ const FIELD_TYPES = [
const FIELD_TYPE_LABELS = Object.fromEntries(FIELD_TYPES);
function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }) {
const [deleting, setDeleting] = useState(false);
const [, setError] = useFeedbackState();
useEffect(() => { setError(''); }, [isOpen, setError]);
const confirmDelete = async () => {
if (deleting) return;
setDeleting(true);
setError('');
try {
await onConfirm?.();
} catch (deleteError) {
setError(deleteError?.message || '删除失败,请稍后重试。');
} finally {
setDeleting(false);
}
};
return (
<AlertDialog.Root>
<AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<AlertDialog.Backdrop isOpen={isOpen} variant="blur">
<AlertDialog.Backdrop
isOpen={isOpen}
isDismissable={!deleting}
isKeyboardDismissDisabled={deleting}
onOpenChange={(open) => { if (!open && !deleting) onCancel?.(); }}
variant="blur"
>
<AlertDialog.Container placement="center" size="md">
<AlertDialog.Dialog className="bg-background">
<AlertDialog.Header>
......@@ -58,8 +83,8 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }
</AlertDialog.Header>
<AlertDialog.Body>{description}</AlertDialog.Body>
<AlertDialog.Footer>
<Button variant="secondary" onPress={onCancel}>取消</Button>
<Button variant="danger" onPress={onConfirm}>确认删除</Button>
<Button isDisabled={deleting} variant="secondary" onPress={onCancel}>取消</Button>
<Button isDisabled={deleting} isPending={deleting} variant="danger" onPress={confirmDelete}>确认删除</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
......@@ -71,14 +96,14 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }
function FormDialog({ open, onClose, onSaved, form }) {
const [title, setTitle] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState();
useEffect(() => {
setError('');
if (open) {
setTitle(form?.title || '');
setError('');
}
}, [open, form]);
}, [open, form, setError]);
const save = async () => {
if (!title.trim()) {
......@@ -123,7 +148,6 @@ function FormDialog({ open, onClose, onSaved, form }) {
<Label>表单标题</Label>
<Input value={title} variant="secondary" onChange={(event) => setTitle(event.target.value)} />
</TextField>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
</div>
</XinyuanModal>
);
......@@ -140,9 +164,10 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
settings: '{}',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState();
useEffect(() => {
setError('');
if (!open) {
return;
}
......@@ -155,8 +180,7 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
primary: Boolean(field?.primary),
settings: JSON.stringify(field?.settings || {}, null, 2),
});
setError('');
}, [open, field]);
}, [open, field, setError]);
const save = async () => {
if (!values.label.trim() || !values.name.trim()) {
......@@ -243,7 +267,6 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
<Label>字段设置 JSON</Label>
<TextArea fullWidth rows={9} variant="secondary" value={values.settings} onChange={(event) => setValues((current) => ({ ...current, settings: event.target.value }))} />
</TextField>
{error ? <Alert className="sm:col-span-2" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
</div>
</XinyuanModal>
);
......@@ -252,8 +275,9 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
function FieldsDialog({ form, open, onClose }) {
const [formDetail, setFormDetail] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loadFailed, setLoadFailed] = useState(false);
const [, setError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const [keyword, setKeyword] = useState('');
const [editingField, setEditingField] = useState(null);
const [fieldDialogOpen, setFieldDialogOpen] = useState(false);
......@@ -263,18 +287,23 @@ function FieldsDialog({ form, open, onClose }) {
const reload = useCallback(async () => {
if (!form?.name || !open) {
setError('');
setSuccess('');
return;
}
setLoading(true);
setLoadFailed(false);
setError('');
try {
setFormDetail(await getDFormDetail(form.name));
} catch (requestError) {
setLoadFailed(true);
setFormDetail(null);
setError(requestError?.message || '字段列表加载失败。');
} finally {
setLoading(false);
}
}, [form?.name, open]);
}, [form?.name, open, setError, setSuccess]);
useEffect(() => {
reload();
......@@ -403,7 +432,7 @@ function FieldsDialog({ form, open, onClose }) {
{ key: 'delete', label: '删除', icon: Delete, tone: 'danger', onClick: ({ record }) => setDeleteField(record.raw) },
]}
collapseRowActions={false}
states={{ loading, error, success, emptyTitle: '暂无字段', emptyDescription: '使用“新建字段”配置表单结构。' }}
states={{ loading, loadFailed, onRetry: reload, emptyTitle: '暂无字段', emptyDescription: '使用“新建字段”配置表单结构。' }}
/>
<XinyuanModal
......@@ -450,8 +479,9 @@ function FieldsDialog({ form, open, onClose }) {
export default function DynamicFormManager() {
const [forms, setForms] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loadFailed, setLoadFailed] = useState(false);
const [, setError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(20);
......@@ -462,17 +492,19 @@ export default function DynamicFormManager() {
const reload = useCallback(async () => {
setLoading(true);
setLoadFailed(false);
setError('');
try {
const result = await queryDForm(0, 9999, {}, null, 'created_at');
setForms(result?.forms || []);
} catch (requestError) {
setLoadFailed(true);
setForms([]);
setError(requestError?.message || '项目表单加载失败。');
} finally {
setLoading(false);
}
}, []);
}, [setError]);
useEffect(() => {
reload();
......@@ -564,7 +596,7 @@ export default function DynamicFormManager() {
setPage(0);
},
}}
states={{ loading, error, success, emptyTitle: '暂无项目表单', emptyDescription: '使用“新建表单”开始配置。' }}
states={{ loading, loadFailed, onRetry: reload, emptyTitle: '暂无项目表单', emptyDescription: '使用“新建表单”开始配置。' }}
/>
<FormDialog
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
AlertDialog,
Button,
Disclosure,
......@@ -117,7 +117,7 @@ function useFormDefinition(instance) {
const identifier = getFormIdentifier(instance?.form);
const [definition, setDefinition] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState();
useEffect(() => {
let cancelled = false;
......@@ -152,7 +152,7 @@ function useFormDefinition(instance) {
return () => {
cancelled = true;
};
}, [identifier]);
}, [identifier, setError]);
return { definition, loading, error };
}
......@@ -239,14 +239,14 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) {
function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
const [values, setValues] = useState({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState();
useEffect(() => {
setError('');
if (isOpen) {
setValues(row && typeof row === 'object' ? row : {});
setError('');
}
}, [isOpen, row]);
}, [isOpen, row, setError]);
const handleSave = async () => {
setSaving(true);
......@@ -294,11 +294,6 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
}))}
/>
))}
{error ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
) : null}
</div>
</XinyuanModal>
);
......@@ -546,7 +541,7 @@ export default function DynamicFormValueEditor({
const instances = useMemo(() => normalizeInstances(value, multiple), [value, multiple]);
const [forms, setForms] = useState([]);
const [formsLoading, setFormsLoading] = useState(false);
const [formsError, setFormsError] = useState('');
const [formsError, setFormsError] = useFeedbackState();
const [activeGroup, setActiveGroup] = useState(DEFAULT_GROUP);
const [newGroupName, setNewGroupName] = useState('');
const [isGroupDialogOpen, setGroupDialogOpen] = useState(false);
......@@ -564,7 +559,10 @@ export default function DynamicFormValueEditor({
);
useEffect(() => {
if (!canEdit) return undefined;
if (!canEdit) {
setFormsError('');
return undefined;
}
let cancelled = false;
setFormsLoading(true);
setFormsError('');
......@@ -581,7 +579,7 @@ export default function DynamicFormValueEditor({
return () => {
cancelled = true;
};
}, [canEdit]);
}, [canEdit, setFormsError]);
const groups = useMemo(() => {
const configured = Array.from(new Set(
......@@ -770,9 +768,7 @@ export default function DynamicFormValueEditor({
<Disclosure.Body>
<div className="space-y-4">
{formsError ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{formsError}</Alert.Description></Alert.Content>
</Alert>
<p className="text-sm text-danger" role="alert">{formsError}</p>
) : null}
{multiple ? (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import clsx from 'clsx';
import { Alert, Button, Checkbox, Input, Label, Surface, TextArea, TextField } from '@heroui/react';
import { Button, Checkbox, Input, Label, Surface, TextArea, TextField } from '@heroui/react';
import ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import FileUploadField from '@/components/FileUploadField';
import FormDatePicker from '@/components/FormDatePicker';
......@@ -191,7 +192,7 @@ function JsonInput({ value, onChange, placeholder }) {
function FileInput({ fieldDef, value, onChange }) {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState();
const handleUpload = async (files) => {
const file = files?.[0];
......@@ -240,14 +241,14 @@ function FileInput({ fieldDef, value, onChange }) {
triggerLabel="选择附件"
/>
{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>
);
}
function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
const [nestedSubject, setNestedSubject] = useState(null);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState();
const [reloadKey, setReloadKey] = useState(0);
const subjectName = getReferenceSubjectName(fieldDef);
useEffect(() => {
......@@ -257,6 +258,8 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return undefined;
}
setNestedSubject(null);
setError('');
loadNestedSubjectDefinition(subjectName)
.then((result) => {
if (!cancelled) {
......@@ -273,7 +276,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return () => {
cancelled = true;
};
}, [subjectName, depth]);
}, [subjectName, depth, reloadKey, setError]);
if (!subjectName || depth >= MAX_SUBJECT_DEPTH) {
const fieldLabel = fieldDef?.label || fieldDef?.name || '字段';
......@@ -287,7 +290,12 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
}
if (error) {
return <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>;
return (
<div className="space-y-2">
<p className="text-sm text-danger" role="alert">{error}</p>
<Button size="sm" variant="secondary" onPress={() => setReloadKey((current) => current + 1)}>重新加载</Button>
</div>
);
}
if (!nestedSubject) {
......@@ -592,7 +600,7 @@ export default function DynamicRecordDialog({
const [title, setTitle] = useState('');
const [values, setValues] = useState({});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState();
const editing = Boolean(record);
const hideTitle = shouldHideViewTitleColumn(view);
const dialogColumns = useMemo(
......@@ -625,6 +633,7 @@ export default function DynamicRecordDialog({
}, [subject, record, restrictionDefaults]);
useEffect(() => {
setError('');
if (!open) {
return;
}
......@@ -632,8 +641,7 @@ export default function DynamicRecordDialog({
!editing ? restrictionDefaults.title ?? '' : ''
));
setValues(initialValues);
setError('');
}, [open, record, initialValues, editing, restrictionDefaults]);
}, [open, record, initialValues, editing, restrictionDefaults, setError]);
const handleSubmit = async () => {
setSaving(true);
......@@ -730,9 +738,6 @@ export default function DynamicRecordDialog({
/>
);
})}
{error ? (
<Alert className="md:col-span-2" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert>
) : null}
</div>
</XinyuanModal>
);
......
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { queryRecords } from '@/services/DataService';
import { Button, Label } from '@heroui/react';
......@@ -310,7 +311,7 @@ export default function ReferenceSearchSelect({
const [options, setOptions] = useState([]);
const [selectedById, setSelectedById] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('warning');
const [active, setActive] = useState(false);
const lastSearchRequestId = useRef(0);
......@@ -477,7 +478,7 @@ export default function ReferenceSearchSelect({
cancelled = true;
clearTimeout(timer);
};
}, [active, pageSize, query, queryFilter, searchFields, subject]);
}, [active, pageSize, query, queryFilter, searchFields, subject, setError]);
useEffect(() => {
if (options.length === 0) {
......
import { AlertDialog, Button } from '@heroui/react';
import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import useFeedbackState from '@/hooks/useFeedbackState';
import {
batchRemoveRecords,
cloneRecord,
......@@ -128,9 +129,12 @@ export default function SubjectRecordWorkbench({
const [records, setRecords] = useState([]);
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [displayConfig, setDisplayConfig] = useState({});
const [, setDisplayConfigError] = useFeedbackState('warning');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loadFailed, setLoadFailed] = useState(false);
const [, setError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const [activeViewName, setActiveViewName] = useState('');
const [filters, setFilters] = useState({});
const [pendingFilters, setPendingFilters] = useState({});
......@@ -148,6 +152,8 @@ export default function SubjectRecordWorkbench({
const requestId = requestSequence.current + 1;
requestSequence.current = requestId;
setLoading(true);
setLoadFailed(false);
setDisplayConfigError('');
setError('');
if (!keepFeedback) {
setSuccess('');
......@@ -165,19 +171,23 @@ export default function SubjectRecordWorkbench({
setTotalRecords(resources.totalCount || 0);
setSubject(resources.subject || null);
setViews(resources.views || []);
setDisplayConfig(resources.displayConfig || {});
setDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) {
if (requestId !== requestSequence.current) return;
setLoadFailed(true);
setRecords([]);
setTotalRecords(0);
setSubject(null);
setViews([]);
setDisplayConfig({});
setError(requestError?.message || '列表加载失败,请稍后重试。');
} finally {
if (requestId === requestSequence.current) {
setLoading(false);
}
}
}, [activeViewName, filters, page, pageSize, query, subjectName]);
}, [activeViewName, filters, page, pageSize, query, subjectName, setDisplayConfigError, setError, setSuccess]);
useEffect(() => {
reload();
......@@ -213,8 +223,8 @@ export default function SubjectRecordWorkbench({
);
const hasAccessibleView = Boolean(activeView);
const configuredView = useMemo(
() => buildConfiguredWorkbenchView(records, {}, activeView, subject),
[records, activeView, subject],
() => buildConfiguredWorkbenchView(records, {}, activeView, subject, displayConfig),
[records, activeView, subject, displayConfig],
);
const filteredRecords = configuredView.filteredRecords || [];
const visibleColumns = useMemo(() => {
......@@ -248,7 +258,7 @@ export default function SubjectRecordWorkbench({
setSuccess,
subject,
subjectName,
}), [reload, subject, subjectName]);
}), [reload, setError, setSuccess, subject, subjectName]);
const wrapAction = (action) => ({
...action,
......@@ -423,8 +433,8 @@ export default function SubjectRecordWorkbench({
} : null}
states={{
loading,
error,
success,
loadFailed,
onRetry: reload,
loadingTitle: `正在加载${title}`,
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: hasAccessibleView ? '当前视图暂无数据' : '暂无可访问的视图',
......
import { Chip, Tooltip } from '@heroui/react';
import { DataTablePrimaryAction, DataTableText } from './DataTable';
import { getTableFieldDisplay } from '@/utils/tableFieldDisplay';
export default function TableFieldValue({ value, column, onPress }) {
const display = getTableFieldDisplay(value, column);
if (display.showCount) {
const count = (
<Chip size="sm" aria-label={`${column.label || '关联条目'}:${display.count} 项`}>
{display.count}
</Chip>
);
const content = onPress ? (
<DataTablePrimaryAction text={display.text} ariaLabel={`${column.label || '关联条目'}:${display.count} 项,查看详情`} onPress={onPress}>
{count}
</DataTablePrimaryAction>
) : count;
return display.labels.length > 0 ? (
<Tooltip>
{onPress ? content : (
<Tooltip.Trigger aria-label={`${column.label || '关联条目'}:${display.count} 项,查看标题`}>
{count}
</Tooltip.Trigger>
)}
<Tooltip.Content>
<div className="max-h-64 max-w-80 overflow-auto whitespace-pre-wrap break-words">
{display.labels.join('、')}
</div>
</Tooltip.Content>
</Tooltip>
) : content;
}
if (onPress) {
return (
<Tooltip>
<DataTablePrimaryAction text={display.text || '-'} onPress={onPress} />
<Tooltip.Content>{display.text || '-'}</Tooltip.Content>
</Tooltip>
);
}
return <DataTableText text={display.text || '-'} maxLines={2} showTooltip />;
}
import useFeedbackState from '@/hooks/useFeedbackState';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Label } from '@heroui/react';
import { loadUser, queryUsers } from '@/services/DataService';
......@@ -21,11 +22,13 @@ export default function UserSearchSelect({
const [users, setUsers] = useState([]);
const [profiles, setProfiles] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const text = keyword.trim();
queryUsers(0, 30, text ? {
$or: [
......@@ -42,14 +45,17 @@ export default function UserSearchSelect({
...Object.fromEntries(nextUsers.map((user) => [getUserId(user), user])),
}));
})
.catch(() => {
if (!cancelled) setUsers([]);
.catch((requestError) => {
if (!cancelled) {
setUsers([]);
setError(requestError?.message || '用户列表加载失败,请重新搜索。');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [keyword]);
}, [keyword, setError]);
useEffect(() => {
const missing = selectedIds.filter((id) => !profiles[id]);
......@@ -81,6 +87,7 @@ export default function UserSearchSelect({
return (
<XinyuanAutocomplete
aria-label={ariaLabel}
errorText={error}
inputValue={keyword}
isDisabled={disabled}
isClearable
......
......@@ -4,9 +4,11 @@ import {
DataTablePersonIdentity,
DataTablePrimaryAction,
DataTableText,
EmptyStateSurface,
isDataTableLongTextField,
} from '@/components/DataTable';
import { KPI } from '@heroui-pro/react/kpi';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
TableActionButton,
TableActionGroup,
......@@ -27,9 +29,10 @@ import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker';
import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect';
import UserSearchSelect from '@/components/UserSearchSelect';
import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import {
Alert,
Avatar,
Button,
Chip,
......@@ -42,7 +45,6 @@ import {
ToggleButton,
Tooltip,
buttonVariants,
toast,
} from '@heroui/react';
import clsx from 'clsx';
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
......@@ -330,6 +332,9 @@ function getFilterPlaceholder(field) {
function defaultRenderCell(record, column) {
const value = getRecordFieldValue(record, column.field);
if (getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue value={value} column={column} />;
}
const text = formatFieldValue(value, column.fieldDef) || '-';
if (column.fieldDef?.type === 'user') {
......@@ -575,13 +580,7 @@ export default function RecordWorkbench({
const [expandedRowIds, setExpandedRowIds] = useState({});
const filterFieldVariant = 'secondary';
const loading = Boolean(states?.loading);
const error = states?.error || '';
const totalRecords = pagination?.totalRecords ?? records.length;
useEffect(() => {
if (states?.success) {
toast.success(states.success, { timeout: 4000 });
}
}, [states?.success]);
const columnGroupMeta = useMemo(
() => buildColumnGroupMeta(columnGroups, columns),
[columnGroups, columns],
......@@ -922,6 +921,9 @@ export default function RecordWorkbench({
if (columnIndex === 0 && detailPanel) {
const value = getRecordFieldValue(row.record, column.field);
if (getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue value={value} column={column} onPress={() => row.rowContext.openDetail(row.record)} />;
}
const text = formatFieldValue(value, column.fieldDef) || '-';
return (
<DataTablePrimaryAction
......@@ -1216,12 +1218,19 @@ export default function RecordWorkbench({
) : null}
</div>
{error ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
{!loading && states?.loadFailed ? (
<EmptyStateSurface>
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>{states.errorTitle || '数据加载失败'}</EmptyState.Title>
<EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
{states.onRetry ? (
<EmptyState.Content><Button variant="secondary" onPress={() => states.onRetry()}>重新加载</Button></EmptyState.Content>
) : null}
</EmptyState>
</EmptyStateSurface>
) : (
<div ref={tableScrollRef} className={tableContentClass}>
<DataTable
ariaLabel={title || '业务数据列表'}
......@@ -1257,6 +1266,7 @@ export default function RecordWorkbench({
/>
<WorkbenchPagination pagination={pagination} />
</div>
)}
</section>
{drawerRecordName && detailPanel?.render
......
......@@ -2,7 +2,7 @@ export const APPLICATION_DISPLAY_NAME = '审计项目管理';
export const APPLICATION_NAME = 'audit_project_manager_new';
export const APPLICATION_BASE_PATH = `/${APPLICATION_NAME}/`;
export const AUTHORIZATION_APPLICATION_NAME = APPLICATION_NAME;
export const SOURCE_APPLICATION_NAME = APPLICATION_NAME;
export const SOURCE_APPLICATION_NAME = APPLICATION_DISPLAY_NAME;
export const ROUTE_TITLES = Object.freeze({
'/audit_project_table/dashboard': '驾驶舱',
......
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 request failures or the current 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, even when the previous message was identical.
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];
}
......@@ -7,7 +7,8 @@ import {
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Card, Input, Label, TextField } from '@heroui/react';
import CryptoJS from 'crypto-js';
import { useContext, useEffect, useState } from 'react';
import { history, useSearchParams } from 'umi';
......@@ -55,7 +56,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState();
const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => {
......@@ -205,13 +206,6 @@ export default function LoginPage() {
</div>
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import {
DataTable,
......@@ -168,7 +169,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
const [subject, setSubject] = useState(null);
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('warning');
useEffect(() => {
let cancelled = false;
......@@ -234,7 +235,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return () => {
cancelled = true;
};
}, [pageSize, populatedRecords, referenceIds, subjectName]);
}, [pageSize, populatedRecords, referenceIds, subjectName, setError]);
if (referenceIds.length === 0) {
return <ReferenceRecordsFrame count={0}><DataTableEmptyState title="暂无关联记录" /></ReferenceRecordsFrame>;
......@@ -267,11 +268,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return (
<div className="space-y-2">
{error ? (
<Alert status="warning">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
<p className="text-sm text-danger" role="alert">{error}</p>
) : null}
<ReferenceRecordsFrame count={referenceIds.length}>
{records.length === 0 ? (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import clsx from 'clsx';
import {
Alert,
Button,
Card,
Checkbox,
......@@ -69,11 +69,7 @@ function FieldError({ children, className }) {
if (!children) return null;
return (
<Alert className={className} status="danger">
<Alert.Content>
<Alert.Description>{children}</Alert.Description>
</Alert.Content>
</Alert>
<p className={clsx('text-sm text-danger', className)} role="alert">{children}</p>
);
}
......@@ -178,6 +174,8 @@ function FieldValue({ value, fieldDef, plainNested = false }) {
function UserSelector({ value, onChange, multiple, placeholder = '请选择用户', variant }) {
const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value],
......@@ -185,6 +183,8 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const loadOptions = async () => {
const filter = keyword.trim()
......@@ -201,10 +201,13 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
if (!cancelled) {
setOptions(result?.users || []);
}
} catch (error) {
} catch (requestError) {
if (!cancelled) {
setOptions([]);
setError(requestError?.message || '用户列表加载失败,请重新搜索。');
}
} finally {
if (!cancelled) setLoading(false);
}
};
......@@ -213,7 +216,7 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
return () => {
cancelled = true;
};
}, [keyword]);
}, [keyword, setError]);
const handleSelectionChange = (keys) => {
if (!multiple) {
......@@ -243,8 +246,10 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
<XinyuanAutocomplete
{...selectionProps}
aria-label="用户"
emptyText="暂无用户"
emptyText={loading ? '正在加载用户...' : '暂无用户'}
errorText={error}
inputValue={keyword}
isLoading={loading}
options={autocompleteOptions}
placeholder={placeholder}
searchPlaceholder="搜索用户名或显示名"
......@@ -315,6 +320,7 @@ function ControlledFieldEditor({
useAuditMaterialCenter,
}) {
const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState();
const [uploading, setUploading] = useState(false);
const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType);
......@@ -329,6 +335,7 @@ function ControlledFieldEditor({
setUploading(true);
setError('');
setRequestError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
try {
......@@ -337,6 +344,7 @@ function ControlledFieldEditor({
onChange?.(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId);
......@@ -490,6 +498,7 @@ function ControlledFieldEditor({
if (!nextValue.trim()) {
setError('');
setRequestError('');
onValidationChange?.(fieldPath, '', editSessionId);
return;
}
......@@ -497,6 +506,7 @@ function ControlledFieldEditor({
try {
JSON.parse(nextValue);
setError('');
setRequestError('');
onValidationChange?.(fieldPath, '', editSessionId);
} catch (parseError) {
const message = 'JSON 格式不正确,请检查后再保存。';
......@@ -569,6 +579,7 @@ export default function TailwindEditableField({
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState();
const [uploading, setUploading] = useState(false);
useEffect(() => {
......@@ -616,6 +627,7 @@ export default function TailwindEditableField({
const save = async (nextValue = draft) => {
setError('');
setRequestError('');
if (fieldType === 'object' && typeof nextValue === 'string' && nextValue.trim()) {
try {
......@@ -631,6 +643,7 @@ export default function TailwindEditableField({
setEditing(false);
} catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。');
setRequestError(saveError?.message || '保存失败,请稍后重试。');
}
};
......@@ -643,6 +656,7 @@ export default function TailwindEditableField({
const cancel = () => {
setDraft(value);
setError('');
setRequestError('');
setEditing(false);
};
......@@ -654,6 +668,7 @@ export default function TailwindEditableField({
setUploading(true);
setError('');
setRequestError('');
try {
const uploaded = await uploadFile(file);
......@@ -664,6 +679,7 @@ export default function TailwindEditableField({
setEditing(false);
} catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。');
setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally {
setUploading(false);
}
......@@ -671,6 +687,7 @@ export default function TailwindEditableField({
const handleRemoveFile = async (indexToRemove) => {
setError('');
setRequestError('');
const nextValue = fieldDef?.multiple
? toArray(draft).filter((_, index) => index !== indexToRemove)
......@@ -685,6 +702,7 @@ export default function TailwindEditableField({
} catch (saveError) {
setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。');
setRequestError(saveError?.message || '删除附件失败,请稍后重试。');
}
};
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useRef, useState } from 'react';
import {
getSubjectView,
......@@ -75,7 +76,7 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState();
const requestIdRef = useRef(0);
useEffect(() => {
......@@ -85,6 +86,7 @@ export function useSubjectRecord(
const loadAll = async () => {
if (!recordName) {
setError('');
setRemoteRecord(null);
setEditableRecord(null);
setSubject(null);
......@@ -146,7 +148,7 @@ export function useSubjectRecord(
return () => {
cancelled = true;
};
}, [recordName, refreshKey, subjectName]);
}, [recordName, refreshKey, subjectName, setError]);
const handleFieldsUpdate = async (changes = []) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
......
import { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip, toast } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Alert, Button, Modal, Spinner, Tabs, Tooltip } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import React, {
useCallback,
useContext,
......@@ -104,8 +106,10 @@ const RecordDetailDrawer = (props, ref) => {
const [draftRecord, setDraftRecord] = useState(null);
const [dirtyFields, setDirtyFields] = useState({});
const [validationErrors, setValidationErrors] = useState({});
const [saveError, setSaveError] = useState('');
const [actionError, setActionError] = useState('');
const [, setSaveError] = useFeedbackState();
const [, setSaveSuccess] = useFeedbackState('success');
const [, setSuccess] = useFeedbackState('success');
const [, setActionError] = useFeedbackState();
const [actionLoadingKey, setActionLoadingKey] = useState('');
const [lastTabSwitchRevision, setLastTabSwitchRevision] = useState('');
const editSessionRef = useRef(0);
......@@ -129,10 +133,11 @@ const RecordDetailDrawer = (props, ref) => {
);
useEffect(() => {
setActionError('');
setSuccess('');
if (open) {
setRefreshKey(Date.now());
setIsFullscreen(false);
setActionError('');
setActionLoadingKey('');
}
......@@ -142,28 +147,11 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
setSaveSuccess('');
setLastTabSwitchRevision('');
pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1;
}, [open, recordName]);
useEffect(() => {
if (error) {
toast.danger(error, { timeout: 4000 });
}
}, [error]);
useEffect(() => {
if (actionError) {
toast.danger(actionError, { timeout: 4000 });
}
}, [actionError]);
useEffect(() => {
if (saveError) {
toast.danger(saveError, { timeout: 4000 });
}
}, [saveError]);
}, [open, recordName, setActionError, setSaveError, setSaveSuccess, setSuccess]);
useEffect(() => {
if (!isEditing) {
......@@ -313,7 +301,7 @@ const RecordDetailDrawer = (props, ref) => {
try {
await action.onClick(actionContext);
if (action.successMessage) {
toast.success(action.successMessage, { timeout: 4000 });
setSuccess(action.successMessage);
}
if (action.refreshAfter) {
refreshRecord({ selectLastTab: action.selectLastTab });
......@@ -348,6 +336,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {};
};
......@@ -364,6 +353,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({});
setValidationErrors({});
setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {};
setIsEditing(true);
};
......@@ -441,6 +431,7 @@ const RecordDetailDrawer = (props, ref) => {
setIsSaving(true);
setSaveError('');
setSaveSuccess('');
try {
const pendingMaterials = Object.entries(pendingLocalMaterialsRef.current)
......@@ -469,6 +460,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors({});
pendingLocalMaterialsRef.current = {};
onDataChange?.(true);
setSaveSuccess('详情已保存。');
} catch (saveFailure) {
setSaveError(saveFailure?.message || '保存失败,请稍后重试。');
} finally {
......@@ -578,7 +570,17 @@ const RecordDetailDrawer = (props, ref) => {
<div className="flex min-h-48 items-center justify-center" role="status">
<Spinner size="sm" />
</div>
) : error ? null : (
) : error ? (
<EmptyState className="min-h-48" size="md">
<EmptyState.Header>
<EmptyState.Title>记录详情加载失败</EmptyState.Title>
<EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => setRefreshKey((current) => current + 1)}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
) : (
<div>
{isRecordLocked ? (
<Alert status="warning">
......
import useFeedbackState from '@/hooks/useFeedbackState';
import xinyuanLogo from '@/assets/xy-logo-red.png';
import {
APPLICATION_BASE_PATH,
......@@ -6,12 +7,11 @@ import {
} from '@/config/application';
import { getApplications } from '@/services/DataService';
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 { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Chip,
Modal,
......@@ -313,7 +313,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
......@@ -351,6 +351,7 @@ export default function ApplicationSwitcherDialog({
}, [fallbackApplications]);
useEffect(() => {
setErrorMessage('');
if (!open) {
setApplicationSearch('');
return;
......@@ -393,7 +394,7 @@ export default function ApplicationSwitcherDialog({
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
}, [fallbackApplications, open, setErrorMessage]);
return (
<Modal.Root>
......@@ -444,12 +445,6 @@ export default function ApplicationSwitcherDialog({
offset={12}
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 ? (
<LoadingSkeleton />
......
import useFeedbackState from '@/hooks/useFeedbackState';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { EmptyStateSurface } from '@/components/DataTable';
import XinyuanModal from '@/components/XinyuanModal';
......@@ -11,7 +12,7 @@ import {
FolderOpenIcon,
ChartPieIcon,
} from '@/components/AppIcons';
import { Alert, Button, Card, Chip, Spinner } from '@heroui/react';
import { Button, Card, Chip, Spinner } from '@heroui/react';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { ListView } from '@heroui-pro/react/list-view';
......@@ -299,7 +300,7 @@ function ProjectListDialog({ group, onClose, onOpenRecord }) {
export default function AuditProjectDashboardPage() {
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState();
const [activeYear, setActiveYear] = useState('all');
const [selectedRecordName, setSelectedRecordName] = useState('');
const [projectGroup, setProjectGroup] = useState(null);
......@@ -315,7 +316,7 @@ export default function AuditProjectDashboardPage() {
} finally {
setLoading(false);
}
}, []);
}, [setError]);
useEffect(() => { loadDashboard(); }, [loadDashboard]);
......@@ -379,23 +380,26 @@ export default function AuditProjectDashboardPage() {
const openRecord = (record) => setSelectedRecordName(record?.name || record?._id || '');
const openGroup = (title, groupRecords) => setProjectGroup({ title, records: groupRecords });
if (loading) {
if (loading && !selectedRecordName) {
return <div className="flex min-h-72 items-center justify-center"><Spinner aria-label="正在加载审计项目驾驶舱" /></div>;
}
if (error) {
if (error && !selectedRecordName) {
return (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
<Button className="mt-3" variant="secondary" onPress={loadDashboard}>重新加载</Button>
</Alert.Content>
</Alert>
<EmptyStateSurface>
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>驾驶舱数据加载失败</EmptyState.Title>
<EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content><Button variant="secondary" onPress={loadDashboard}>重新加载</Button></EmptyState.Content>
</EmptyState>
</EmptyStateSurface>
);
}
return (
<div className="rc-page-stack">
<div aria-busy={loading} className="rc-page-stack">
<div className="flex justify-end">
<XinyuanAutocomplete
aria-label="统计年份"
......
......@@ -2,6 +2,7 @@ import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { getSubjectViewDetail, loadRecord, loadSubject } from '@/services/DataService';
import { useUserInfo } from '@/hooks/user';
import useFeedbackState from '@/hooks/useFeedbackState';
import { buildViewColumns } from '@/utils/subjectView';
import { CheckCircleIcon } from '@/components/AppIcons';
import { useCallback, useEffect, useMemo, useState } from 'react';
......@@ -24,8 +25,9 @@ export default function AuditProjectFormPage() {
const [view, setView] = useState(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loadFailed, setLoadFailed] = useState(false);
const [, setError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const queryParameters = useMemo(() => {
const params = new URLSearchParams(location.search);
......@@ -49,6 +51,10 @@ export default function AuditProjectFormPage() {
restrictionValues,
} = queryParameters;
if (!subjectName || !viewName) {
setSubject(null);
setView(null);
setRecords([]);
setLoadFailed(true);
setLoading(false);
setError('链接缺少 subject 或 view 参数,无法加载审计项目填写表。');
return;
......@@ -63,6 +69,7 @@ export default function AuditProjectFormPage() {
: {};
setLoading(true);
setLoadFailed(false);
setError('');
try {
const [subjectData, viewData, recordData] = await Promise.all([
......@@ -80,6 +87,7 @@ export default function AuditProjectFormPage() {
setView(viewData);
setRecords(scopedRecords.map(normalizeWorkbenchRecord));
} catch (requestError) {
setLoadFailed(true);
setSubject(null);
setView(null);
setRecords([]);
......@@ -87,7 +95,7 @@ export default function AuditProjectFormPage() {
} finally {
setLoading(false);
}
}, [queryParameters]);
}, [queryParameters, setError]);
useEffect(() => {
document.title = '审计项目填写表';
......@@ -159,8 +167,8 @@ export default function AuditProjectFormPage() {
}}
states={{
loading,
error,
success,
loadFailed,
onRetry: loadPage,
loadingTitle: '正在加载审计项目填写表',
loadingDescription: '正在读取视图、字段结构和受限记录。',
emptyTitle: '当前链接范围暂无记录',
......
......@@ -14,6 +14,8 @@ import {
toArray,
} from '@/utils/subjectView';
import { loadApplicationTableDisplayConfig } from '@/services/tableFieldDisplayService';
const QUERY_PAGE_SIZE = 1000;
const DEFAULT_RECORD_SORT = '-updated_at -created_at';
const RECORD_LEVEL_FIELDS = new Set([
......@@ -276,9 +278,10 @@ export async function loadSubjectWorkbenchResources(
query = {},
{ viewName = '', filters = {}, page = 0, pageSize = 20 } = {},
) {
const [subject, views] = await Promise.all([
const [subject, views, tableDisplayConfig] = await Promise.all([
loadSubject(subjectName),
getSubjectView(subjectName),
loadApplicationTableDisplayConfig(subjectName),
]);
const accessibleGridViews = buildGridViews(views || []);
const activeView = accessibleGridViews.find((view) => view.name === viewName)
......@@ -301,6 +304,7 @@ export async function loadSubjectWorkbenchResources(
totalCount: pageResult.totalCount,
subject,
views: views || [],
...tableDisplayConfig,
};
}
......@@ -316,10 +320,10 @@ export function buildGridViews(views = []) {
return isViewAccessible(fallbackView) ? [fallbackView] : [];
}
export function buildConfiguredWorkbenchView(records, _filters, view, subject) {
export function buildConfiguredWorkbenchView(records, _filters, view, subject, displayConfig = {}) {
if (!view) return { columns: [], filteredRecords: [] };
const columns = buildViewColumns(view, subject);
const columns = buildViewColumns(view, subject, { displayConfig });
const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record;
......
......@@ -5,7 +5,8 @@ import {
queryAuditMaterials,
uploadAuditProjectFile,
} from '../auditProjectMaterialService';
import { Alert, Button, Label, Tabs, Tooltip } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import { Button, Label, Tabs, Tooltip } from '@heroui/react';
import { useEffect, useMemo, useState } from 'react';
const MATERIAL_CENTER_TAB = 'material-center';
......@@ -44,10 +45,10 @@ export default function AuditProjectMaterialField({
const [materials, setMaterials] = useState([]);
const [selectedMaterialId, setSelectedMaterialId] = useState(null);
const [materialsLoading, setMaterialsLoading] = useState(false);
const [materialsError, setMaterialsError] = useState('');
const [materialsError, setMaterialsError] = useFeedbackState();
const [retryKey, setRetryKey] = useState(0);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState('');
const [, setUploadError] = useFeedbackState();
const [localFileKeys, setLocalFileKeys] = useState(() => new Set());
const fileValues = useMemo(
() => (multiple ? toFileArray(value) : toFileArray(value).slice(0, 1)),
......@@ -84,7 +85,7 @@ export default function AuditProjectMaterialField({
})
.finally(() => { if (!cancelled) setMaterialsLoading(false); });
return () => { cancelled = true; };
}, [activeTab, debouncedKeyword, retryKey]);
}, [activeTab, debouncedKeyword, retryKey, setMaterialsError]);
const materialOptions = useMemo(() => materials.map((material) => ({
...material,
......@@ -243,9 +244,10 @@ export default function AuditProjectMaterialField({
)}
/>
{materialsError ? (
<Alert status="danger"><Alert.Content><Alert.Description>{materialsError}</Alert.Description></Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}><ArrowPathIcon size={14} />重试</Button>
</Alert>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-danger" role="alert">{materialsError}</p>
<Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}><ArrowPathIcon size={14} />重试</Button>
</div>
) : null}
</div>
</Tabs.Panel>
......@@ -260,7 +262,6 @@ export default function AuditProjectMaterialField({
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload}
/>
{uploadError ? <Alert status="danger"><Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content></Alert> : null}
</div>
</Tabs.Panel>
</Tabs>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination, EmptyStateSurface } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
......@@ -11,7 +11,6 @@ import {
SearchField,
Spinner,
Tabs,
toast,
} from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi';
......@@ -130,7 +129,9 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState();
const [, setOperationError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
......@@ -162,7 +163,7 @@ export default function NotificationListPage() {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [category, keyword, page, readStatus, reloadKey]);
}, [category, keyword, page, readStatus, reloadKey, setError]);
useEffect(() => {
let cancelled = false;
......@@ -175,18 +176,20 @@ export default function NotificationListPage() {
const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const runItemOperation = useCallback(async (key, operation, successMessage) => {
setBusyKey(key);
setOperationError('');
setSuccess('');
try {
await operation();
toast.success(successMessage, { timeout: 3000 });
setSuccess(successMessage);
refresh();
return true;
} catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 });
setOperationError(operationError?.message || '操作失败,请稍后重试');
return false;
} finally {
setBusyKey('');
}
}, [refresh]);
}, [refresh, setOperationError, setSuccess]);
const handleMarkRead = useCallback((notification) => runItemOperation(
notification.name,
......@@ -203,6 +206,7 @@ export default function NotificationListPage() {
), [runItemOperation]);
const handleOpenTarget = useCallback(async (notification, target) => {
setBusyKey(notification.name);
setOperationError('');
try {
if (!isNotificationRead(notification, userInfo?._id)) {
await markNotificationRead(notification.name);
......@@ -211,11 +215,11 @@ export default function NotificationListPage() {
else history.push(target.href);
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 });
setOperationError(operationError?.message || '无法打开通知关联流程');
} finally {
setBusyKey('');
}
}, [refresh, userInfo?._id]);
}, [refresh, setOperationError, userInfo?._id]);
return (
<div className="rc-page-stack">
......@@ -253,10 +257,16 @@ export default function NotificationListPage() {
</Tabs>
</div>
{error ? (
<Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>重试</Button>
</Alert>
{!loading && error ? (
<EmptyStateSurface>
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>通知列表加载失败</EmptyState.Title>
<EmptyState.Description>请重新加载通知,详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content><Button variant="secondary" onPress={refresh}>重试</Button></EmptyState.Content>
</EmptyState>
</EmptyStateSurface>
) : null}
{loading && notifications.length === 0 ? (
<div className="flex min-h-64 items-center justify-center" aria-label="通知加载中"><Spinner size="sm" /></div>
......
......@@ -50,6 +50,48 @@
8. 列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。
9. 业务列表、动态表单数据表和关联记录表中的长文本最多展示两行,内容始终限制在所属列内;超出内容通过 HeroUI Tooltip 或项目详情查看,避免遮挡相邻列或由单条长文本过度拉高整行。弹窗内只读关联记录表按实际数据行高度收缩,不提供无业务价值的列宽拖拽。
### 2.1 表格字段显示配置
入口为 streams-admin 的应用布局或 Subject Grid 视图配置,作用于 `/audit_project`(`audit_project`)和
`/audit_project_archive`(`audit_project_history`)的引用、选择、子对象及 form 列,共享 RecordWorkbench
也支持传入同样的列配置。页面加载、切换视图或刷新时读取应用和视图配置,再按当前列配置展示。
- 应用默认值来自 `audit_project_manager_new` 的
`layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`。
- 配置按属性逐级覆盖:应用默认值 → `settings.displayConfig` → 单列 `displayConfig` →
`settings.columns[]` 上的 `displayMode`、`maxTagCount`、`titleField`。
- `displayMode: "labels"` 显示已选选项或关联标题;选择字段文案来自 `settings.options`,只展示实际选中的值。
- `displayMode: "count"` 显示有效条目数量,忽略 null、undefined 和空白字符串;空值显示 `0`,单值显示 `1`。
数量标签通过悬停或键盘聚焦查看完整标题,空值不产生提示;首列仍可点击打开详情。
- `displayMode: "auto"` 在非空条目数达到 `maxTagCount` 时显示数量,否则显示标题,判断使用 `>=`。
只配置合法 `maxTagCount` 时自动启用 auto;显式 auto 未配置合法阈值时使用 `3`;阈值为 `0` 时所有非空值显示数量。
阈值必须为非负整数(兼容数字字符串)。
- 子对象通过 `titleField` 读取子字段(支持嵌套路径及 metadata 下的字段);值为空时回退到已有标题、名称或 ID,
仍无标题时显示“第 N 条”。单个 `{form,data}` 表单对象按 data 行计数,表单对象数组按数组条目计数,与参考提交一致。
- 未配置合法模式或阈值时保留原单元格行为,包括表单名称和“表单数量(实例数)”摘要。
普通文本、数字、日期、布尔、附件和人员不受集合显示配置影响。
- 加载期间沿用列表加载状态;成功后应用配置。应用配置读取失败时显示警告并回退到视图配置,记录仍可加载和打开详情;
下次刷新重试,成功后清除警告。记录或 Subject/View 请求失败仍按列表原有错误流程处理。
- 只读取配置与当前字段已有数据,不额外查询关联总数,不改变查询筛选、排序、分页、状态流转和 Record metadata。
应用默认配置示例:在 `layouts.default.subjects.audit_project.gridRecordConfig.displayConfig` 设置
`{ "maxTagCount": 3 }`;归档使用相同路径下的 `audit_project_history` 独立配置。
Grid 视图示例(配置片段,维护时保留完整原列列表):
```json
{
"settings": {
"displayConfig": { "displayMode": "auto", "maxTagCount": 3 },
"columns": [
{ "field": "metadata.relevant_regulations", "displayMode": "count" },
{ "field": "metadata.relevant_systems", "displayMode": "labels" },
{ "field": "metadata.project_form", "displayMode": "count" }
]
}
}
```
## 3. 状态流转
```text
......@@ -141,7 +183,7 @@ Ant Design Icons。
模板标识,也不继续展示缺少定义的表格;编辑态可以重新选择有效模板,现有数据不会自动改写。
- 模板存在但缺少字段定义时显示“暂无可展示字段”,不使用已有数据对象的内部 key 临时生成
英文表头;现有行数据保持原样,待模板字段定义恢复后再按业务标签展示。
- 业务列表中的 `form` 字段在单值时只显示表单名称;多值时显示“表单数量(表单实例数)”摘要标签,不直接展示 `{ form, data, settings }` JSON,也不展开多个表单实例或表单名称。
- 未配置表格字段显示模式或阈值时,业务列表中的 `form` 字段在单值时只显示表单名称;多值时显示“表单数量(表单实例数)”摘要标签,不直接展示 `{ form, data, settings }` JSON,也不展开多个表单实例或表单名称。
- 删除为不可恢复操作,执行前必须二次确认;接口失败时保留当前页面并展示错误。
## 9. 权限与限制
......@@ -178,6 +220,35 @@ Ant Design Icons。
远程搜索;选择后只把 `{name,type,size,url}` 快照写入业务草稿。
- 本地文件先调用 `/upload` 并进入当前详情草稿。取消编辑不创建材料主档;单值替换,多值追加并
按 URL 去重,移除或清空业务附件不删除材料中心主档或底层文件。
- 用户点击详情“保存”后,系统才用 `source_app = "audit_project_manager_new"`、当前用户、字段快照和
- 用户点击详情“保存”后,系统才用当前应用中文名称 `source_app = "审计项目管理"`(统一读取
`APPLICATION_DISPLAY_NAME`)、当前用户、字段快照和
ISO 时间逐个创建 `am_material`。全部建档成功后才调用既有业务记录更新回调;部分失败会在当前
编辑会话保留已建档标记,重试只创建尚未成功的材料,避免重复主档。
## 12. 操作提示与异常恢复
- 登录和验证码、站点配置、切换应用、项目与归档列表、驾驶舱、外部填写表、详情保存、阶段动作、
项目表单与字段维护、引用/用户搜索、材料查询和附件上传的成功、失败及降级警告统一使用 HeroUI
Toast 浮层;接口结果不再以页面常驻横条展示。
- 提示挂载在应用根部,通过官方 Portal 显示在视口顶部,层级高于业务 Modal、删除确认和移动侧栏;
页面滚动和弹窗打开时仍可看到提示。出现提示不主动移动表单焦点。当前提示正文右侧的关闭图标
始终可见,使用 HeroUI 小号透明图标按钮,通过 Toast 的 `close` slot 支持点击和键盘关闭;后排
堆叠提示隐藏关闭按钮,避免误关闭。按钮参与正文布局,沿用官方 Hover/焦点样式。
- 成功提示默认 4 秒消失,错误和警告默认 8 秒消失;悬停或键盘聚焦时暂停计时。同一来源再次提交
时替换上一条提示,相同错误也会重新显示;清空反馈或组件卸载时关闭该来源提示。
- 关闭或自动隐藏提示只改变展示,不清除请求失败状态、表单草稿、所选记录、字段校验错误或已建档
标记,不触发保存、删除、归档或状态流转。JSON 输入校验仍在字段附近显示文本,不随每次输入弹出提示。
- 详情弹窗点击“保存”后,只有本次待建档 `am_material` 和业务 `updateRecord` 全部成功,才退出编辑
并显示“详情已保存。”成功提示,弹窗继续展示已保存内容。任一写入失败仅显示错误并保留草稿。
重新编辑、取消编辑、再次保存、关闭弹窗或切换记录时清理上一条保存成功提示;无修改、校验失败或
正在保存时不提交、不显示成功。驾驶舱在详情数据变化后继续刷新统计,刷新期间保留已打开的详情
弹窗与提示;统计刷新失败单独显示加载错误,不清除已保存的详情。
- 列表、驾驶舱、外部填写表、项目表单/字段列表和记录详情加载失败时保留可重新加载的空状态,详细
原因由浮层提示;重试只重新读取数据。保存、删除、导入、复核或阶段动作失败时保留当前编辑/确认
界面,不将操作失败误判为整个列表加载失败;删除请求期间禁用重复确认,失败后允许再次确认。
- 应用表格显示配置读取失败时使用 warning 浮层并保留视图配置回退;下一次刷新重试。引用、人员、
子对象和材料加载错误可同时保留字段附近的错误文本或重试入口,关闭浮层不把失败当作空结果或成功。
- 通知列表加载失败时保留“重试”空状态;已读、全部已读和待办处理的操作反馈使用相同浮层规则。
关闭错误提示不修改未读数,也不伪造服务端处理成功。
- 继续使用既有 Record、Subject/View、form、notice、auth、info 和 upload 接口;`audit_project`、
`audit_project_history`、`am_material` 的字段、metadata 写入协议和状态流转不因提示展示改变。
......@@ -44,6 +44,19 @@
- `404` `{"code":404,"message":"record '<name>' not found"}`
- `400` 业务校验失败(缺少必填字段、主键冲突、类型不匹配等)
### 1.2 表格显示配置的关联读取接口
项目列表和往期归档通过已有 `DataService.loadApplication(APPLICATION_NAME)` 调用 `GET /application/:name`,
读取 `audit_project_manager_new` 应用的 `layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`。
Subject 分别为 `audit_project`、`audit_project_history`,路径与 streams-admin 的应用布局配置一致。
视图继续通过 `getSubjectView`(`GET /subject/:subjectName/view`)读取;完整视图接口为
`getSubjectViewDetail`(`GET /subject/:subjectName/view/:viewName`)。视图默认配置位于 `settings.displayConfig`,
单列覆盖位于 `settings.columns[]`。支持 `displayMode: labels | count | auto`、非负整数 `maxTagCount` 和子字段路径 `titleField`。
只读取配置,不更新应用、视图、Subject 或 Record。应用配置失败时显示警告并使用视图配置,记录查询与写入协议不变。
配置优先级、交互、错误处理及示例见 `src/pages/project_manager_xy/project-management.business-flow.md` 第 2.1 节。
## 2. Record 核心模型字段
不同 `subject` 通过 `metadata` 扩展业务字段。
......
import { APPLICATION_NAME } from '@/config/application';
import { loadApplication } from '@/services/DataService';
export async function loadApplicationTableDisplayConfig(subjectName) {
try {
const application = await loadApplication(APPLICATION_NAME);
return {
displayConfig: application?.layouts?.default?.subjects?.[subjectName]?.gridRecordConfig?.displayConfig || {},
displayConfigError: '',
};
} catch (error) {
return {
displayConfig: {},
displayConfigError: `应用表格显示配置读取失败,暂使用视图默认显示:${error?.message || '请稍后重试'}`,
};
}
}
......@@ -31,6 +31,18 @@ html {
color-scheme: light !important;
}
/* Keep feedback above dialogs and mobile sidebars, outside page scroll containers. */
.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;
}
body {
margin: 0;
font-family: var(--rc-font-sans);
......
......@@ -291,9 +291,10 @@ export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true;
}
export function buildViewColumns(view, subject, { includeHidden = false } = {}) {
export function buildViewColumns(view, subject, { includeHidden = false, displayConfig = {} } = {}) {
const configuredColumns = view?.settings?.columns || [];
const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view);
const viewDisplayConfig = { ...displayConfig, ...view?.settings?.displayConfig };
if (configuredColumns.length > 0) {
return configuredColumns
......@@ -310,6 +311,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
(column.field === 'title' ? '标题' : '其他信息');
return {
...column,
displayConfig: { ...viewDisplayConfig, ...column.displayConfig },
width: column.width || 180,
label,
fieldDef: fieldDef || { name: fieldName, label, type: 'text' },
......@@ -321,6 +323,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
if (!hideTitleColumn) {
defaultColumns.push({
field: 'title',
displayConfig: viewDisplayConfig,
width: 180,
label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' },
......@@ -335,6 +338,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
'其他信息';
defaultColumns.push({
field: fieldPath,
displayConfig: viewDisplayConfig,
width: 180,
label,
fieldDef: field,
......
import {
formatFieldValue,
getFieldValue,
getSelectOptions,
isReferenceFieldType,
normalizeRecordLabel,
normalizeText,
toArray,
} from './subjectView';
export const TABLE_FIELD_DISPLAY_MODES = ['labels', 'count', 'auto'];
export const DEFAULT_TABLE_MAX_TAG_COUNT = 3;
export function isTableFieldDisplayConfigurable(column) {
const type = column?.fieldDef?.type;
return isReferenceFieldType(type) || ['select', 'subject', 'form'].includes(type);
}
export function getTableFieldDisplayConfig(column) {
const config = column?.displayConfig || {};
const configuredMode = column?.displayMode ?? config.displayMode;
const configuredThreshold = column?.maxTagCount ?? config.maxTagCount;
const threshold = Number(configuredThreshold);
const hasThreshold = ['number', 'string'].includes(typeof configuredThreshold) &&
normalizeText(configuredThreshold) !== '' && Number.isInteger(threshold) && threshold >= 0;
const hasMode = TABLE_FIELD_DISPLAY_MODES.includes(configuredMode);
return {
isConfigured: isTableFieldDisplayConfigurable(column) && (hasMode || hasThreshold),
displayMode: hasMode ? configuredMode : hasThreshold ? 'auto' : 'labels',
maxTagCount: hasThreshold ? threshold : DEFAULT_TABLE_MAX_TAG_COUNT,
titleField: normalizeText(column?.titleField ?? config.titleField),
};
}
function getDisplayItems(value, fieldDef) {
const items = fieldDef?.type === 'form' && value && !Array.isArray(value) && Array.isArray(value.data)
? value.data
: toArray(value);
return items.filter((item) =>
item !== null && item !== undefined && !(typeof item === 'string' && !item.trim()),
);
}
export function getTableFieldDisplay(value, column) {
const config = getTableFieldDisplayConfig(column);
const items = getDisplayItems(value, column?.fieldDef);
const type = column?.fieldDef?.type;
const options = type === 'select' ? getSelectOptions(column.fieldDef) : [];
const labels = items.map((item, index) => {
if (type === 'select') {
const optionValue = item && typeof item === 'object' ? item.value : item;
const option = options.find((candidate) => String(candidate.value) === String(optionValue));
return normalizeText(option?.label ?? optionValue);
}
if (['subject', 'form'].includes(type)) {
const titleValue = config.titleField
? getFieldValue(item, config.titleField) ?? getFieldValue(item?.metadata, config.titleField)
: null;
return formatFieldValue(titleValue) || normalizeRecordLabel(item) || `第 ${index + 1} 条`;
}
if (type === 'tag') return normalizeText(item?.label) || normalizeRecordLabel(item);
return normalizeRecordLabel(item);
}).filter(Boolean);
const showCount = config.displayMode === 'count' ||
(config.displayMode === 'auto' && items.length > 0 && items.length >= config.maxTagCount);
return {
...config,
count: items.length,
labels,
showCount,
text: showCount ? String(items.length) : labels.join('、'),
};
}
export function formatTableFieldValue(value, column) {
const display = getTableFieldDisplay(value, column);
return display.isConfigured ? display.text : formatFieldValue(value, column?.fieldDef);
}
import useFeedbackState from '@/hooks/useFeedbackState';
import { getSystemInfo } from '@/services/DataService';
import { Spinner } from '@heroui/react';
import dayjs from 'dayjs';
......@@ -19,7 +20,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [loadError, setLoadError] = useFeedbackState();
useEffect(() => {
let cancelled = false;
......@@ -55,7 +56,7 @@ export default function SiteWrapper() {
return () => {
cancelled = true;
};
}, []);
}, [setLoadError]);
if (loading) {
return (
......
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import test from 'node:test';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const sourceRoot = new URL('../src/', import.meta.url).pathname;
function loadModule(entry, dataService = {}) {
const modules = new Map();
const load = (path) => {
if (modules.has(path)) return modules.get(path);
const exports = {};
modules.set(path, exports);
const { outputText } = ts.transpileModule(readFileSync(path, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
});
const localRequire = (specifier) => {
if (specifier === './DataService' || specifier === '@/services/DataService') return dataService;
if (!specifier.startsWith('.') && !specifier.startsWith('@/')) return require(specifier);
const dependencyPath = specifier.startsWith('@/')
? resolve(sourceRoot, specifier.slice(2))
: resolve(dirname(path), specifier);
return load(`${dependencyPath}.js`);
};
new Function('require', 'exports', outputText)(localRequire, exports);
return exports;
};
return load(resolve(sourceRoot, entry));
}
const { getTableFieldDisplay, getTableFieldDisplayConfig, formatTableFieldValue } = loadModule('utils/tableFieldDisplay.js');
const { buildViewColumns } = loadModule('utils/subjectView.js');
const column = (type, config = {}) => ({ field: 'metadata.values', fieldDef: { type }, ...config });
test('count mode counts selected values, including zero, and excludes empty entries', () => {
const config = column('select', { displayMode: 'count' });
assert.equal(formatTableFieldValue([0, '选中', null, undefined, '', ' '], config), '2');
for (const value of [null, undefined, '', []]) assert.equal(formatTableFieldValue(value, config), '0');
assert.equal(formatTableFieldValue(0, config), '1');
assert.equal(formatTableFieldValue({ _id: 'one', title: '关联标题' }, column('reference', { displayMode: 'count' })), '1');
});
test('labels show selected option labels and populated record titles', () => {
const config = column('select', { displayMode: 'labels' });
config.fieldDef.settings = { options: [{ value: 0, label: '零号' }, { value: 'a', label: '选项 A' }, { value: 'b', label: '未选项' }] };
assert.equal(formatTableFieldValue(['0', 'a'], config), '零号、选项 A');
assert.equal(formatTableFieldValue([{ _id: 'id-1', title: '记录 A' }, { _id: 'id-2', title: '记录 B' }], column('reference', { displayMode: 'labels' })), '记录 A、记录 B');
assert.equal(formatTableFieldValue([{ name: 'risk', label: '风险标签' }], column('tag', { displayMode: 'labels' })), '风险标签');
});
test('maxTagCount alone matches streams-admin at the exact threshold', () => {
const config = column('reference', { displayConfig: { maxTagCount: 2 } });
assert.equal(formatTableFieldValue([{ title: 'A' }], config), 'A');
assert.equal(formatTableFieldValue([{ title: 'A' }, { title: 'B' }], config), '2');
assert.equal(formatTableFieldValue([], config), '');
assert.equal(formatTableFieldValue(['A'], column('reference', { displayMode: 'auto', maxTagCount: 0 })), '1');
assert.equal(getTableFieldDisplayConfig(column('reference', { displayMode: 'auto' })).maxTagCount, 3);
});
test('embedded subjects and forms use configured child labels without changing data', () => {
const value = [{ responsible_department: '审计部' }, { metadata: { responsible_department: '风险管理部' } }];
const original = JSON.stringify(value);
const config = column('subject', { displayMode: 'labels', titleField: 'responsible_department' });
assert.equal(formatTableFieldValue(value, config), '审计部、风险管理部');
assert.equal(getTableFieldDisplay(value, { ...config, displayMode: 'count' }).count, 2);
assert.equal(formatTableFieldValue([{}, {}], config), '第 1 条、第 2 条');
assert.equal(formatTableFieldValue({ form: { title: '表单' }, data: value }, column('form', { displayMode: 'count' })), '2');
assert.equal(JSON.stringify(value), original);
});
test('application defaults, view settings, and column overrides keep independent modes', () => {
const subject = { fields: [{ name: 'values', type: 'select' }] };
const view = { settings: {
displayConfig: { maxTagCount: 2 },
columns: [{ field: 'metadata.values', displayMode: 'labels' }],
} };
const original = JSON.stringify(view);
const [config] = buildViewColumns(view, subject, { displayConfig: { displayMode: 'count', maxTagCount: 5 } });
assert.equal(getTableFieldDisplayConfig(config).maxTagCount, 2);
assert.equal(formatTableFieldValue(['A', 'B'], config), 'A、B');
const [nestedConfig] = buildViewColumns({ settings: {
displayConfig: { displayMode: 'count', maxTagCount: 5 },
columns: [{ field: 'metadata.values', displayConfig: { displayMode: 'auto', maxTagCount: 2 } }],
} }, subject);
assert.equal(formatTableFieldValue(['A', 'B'], nestedConfig), '2');
const fallback = buildViewColumns({ settings: {} }, subject, { displayConfig: { displayMode: 'count' } });
assert.equal(formatTableFieldValue(['A', 'B'], fallback.find((item) => item.field === 'metadata.values')), '2');
assert.equal(JSON.stringify(view), original);
});
test('missing or invalid config preserves current behavior and does not affect scalar fields', () => {
const value = [{ responsible_department: '审计部' }];
assert.equal(formatTableFieldValue(value, column('subject')), loadModule('utils/subjectView.js').formatFieldValue(value, { type: 'subject' }));
assert.equal(formatTableFieldValue('A', column('select', { displayMode: 'invalid', maxTagCount: -1 })), 'A');
for (const type of ['text', 'number', 'date', 'boolean', 'user', 'file', 'object']) {
assert.equal(getTableFieldDisplayConfig(column(type, { displayMode: 'count' })).isConfigured, false);
}
for (const maxTagCount of ['invalid', -1, 1.5, null, '', ' ', false, true, []]) {
assert.equal(getTableFieldDisplayConfig(column('reference', { displayConfig: { maxTagCount } })).isConfigured, false);
}
});
test('application config is read from the same layout path as streams-admin', async () => {
const requests = [];
const service = loadModule('services/tableFieldDisplayService.js', {
loadApplication: async (name) => {
requests.push(name);
return { layouts: { default: { subjects: { audit_project: {
gridRecordConfig: { displayConfig: { maxTagCount: 2 } },
} } } } };
},
});
assert.deepEqual(await service.loadApplicationTableDisplayConfig('audit_project'), {
displayConfig: { maxTagCount: 2 }, displayConfigError: '',
});
assert.deepEqual(requests, ['audit_project_manager_new']);
assert.deepEqual(await service.loadApplicationTableDisplayConfig('audit_project_history'), {
displayConfig: {}, displayConfigError: '',
});
});
test('unavailable application config returns a warning and usable view fallback', async () => {
const service = loadModule('services/tableFieldDisplayService.js', {
loadApplication: async () => { throw new Error('无权限'); },
});
const result = await service.loadApplicationTableDisplayConfig('audit_project');
assert.deepEqual(result.displayConfig, {});
assert.match(result.displayConfigError, /暂使用视图默认显示.*无权限/);
});
test('project and archive workbenches carry application config through columns and keep querying after config failure', async () => {
const previousWindow = globalThis.window;
globalThis.window = { localStorage: { getItem: () => JSON.stringify({ type: 'admin' }) } };
try {
for (const subjectName of ['audit_project', 'audit_project_history']) {
for (const configFails of [false, true]) {
const requests = [];
const subject = { fields: [{ name: 'relevant_regulations', type: 'reference', multiple: true }] };
const view = { name: 'all', type: 'grid', settings: {
displayConfig: { maxTagCount: 2 },
columns: [{ field: 'metadata.relevant_regulations' }],
} };
const rawRecord = { _id: 'record-1', metadata: { relevant_regulations: [{ title: '法规 A' }, { title: '法规 B' }] } };
const service = loadModule('pages/project_manager_xy/SubjectWorkbenchService.js', {
loadSubject: async (name) => { assert.equal(name, subjectName); return subject; },
getSubjectView: async (name) => { assert.equal(name, subjectName); return [view]; },
loadApplication: async () => {
if (configFails) throw new Error('配置暂不可用');
return { layouts: { default: { subjects: { [subjectName]: {
gridRecordConfig: { displayConfig: { displayMode: 'labels', maxTagCount: 5 } },
} } } } };
},
queryRecords: async (query) => {
requests.push(query);
return { records: [rawRecord], total_count: 1 };
},
});
const resources = await service.loadSubjectWorkbenchResources(subjectName, {}, { page: 1, pageSize: 20 });
assert.equal(requests.length, 1);
assert.equal(requests[0].subject, subjectName);
assert.equal(requests[0].page, 1);
assert.equal(requests[0].page_size, 20);
assert.equal(Boolean(resources.displayConfigError), configFails);
const result = service.buildConfiguredWorkbenchView(resources.records, {}, view, subject, resources.displayConfig);
assert.equal(result.filteredRecords[0].raw, rawRecord);
assert.equal(getTableFieldDisplayConfig(result.columns[0]).maxTagCount, 2);
assert.equal(formatTableFieldValue(rawRecord.metadata.relevant_regulations, result.columns[0]), configFails ? '2' : '法规 A、法规 B');
}
}
} finally {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
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