Commit 69823071 authored by drigle's avatar drigle

feat: unify app feedback and configure table field display

parent 8d6af315
...@@ -266,8 +266,12 @@ Inter 与系统回退字体之间切换。 ...@@ -266,8 +266,12 @@ Inter 与系统回退字体之间切换。
- 严谨业务界面不使用绿色系、粉色、玫红或洋红危险色;HeroUI 主色固定为鑫元基金品牌红 - 严谨业务界面不使用绿色系、粉色、玫红或洋红危险色;HeroUI 主色固定为鑫元基金品牌红
`#DA241A`,由 `--accent`/`--primary` 提供,Hover 使用 `#C91F17`,Pressed 使用 `#B51B14`。 `#DA241A`,由 `--accent`/`--primary` 提供,Hover 使用 `#C91F17`,Pressed 使用 `#B51B14`。
`danger` / `danger-soft` 继续继承品牌红深色阶,不允许被 HeroUI 默认蓝色或其它主题色覆盖。 `danger` / `danger-soft` 继续继承品牌红深色阶,不允许被 HeroUI 默认蓝色或其它主题色覆盖。
- 详情请求失败、详情动作失败和保存失败使用 HeroUI `toast.danger` 短提醒(约 4 秒自动消失), - 详情请求、详情动作和保存反馈统一使用根部 `AppFeedback` 的 HeroUI Toast;成功默认 4 秒、错误和
不在弹窗内容流中渲染持续占位的错误 Alert;需要用户持续处理的锁定、字段校验等业务提示仍保留在上下文中。 警告默认 8 秒自动消失,不在弹窗内容流中渲染持续占位的接口反馈 Alert。详情保存全部成功后提示
“详情已保存。”,保留弹窗和已保存内容;锁定、字段校验等需要持续处理的业务提示保留在上下文中。
- Toast 关闭按钮使用 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`),通过官方 `slot="close"`
接入关闭行为,组合 `AppIcons.Close` 并位于正文右侧参与布局。前排提示的关闭图标始终可见,后排
堆叠提示隐藏关闭按钮;沿用官方 Hover/焦点样式,不添加带边框的放大关闭按钮、绝对定位或正文占位。
## 禁止事项 ## 禁止事项
......
{ {
"name": "audit_project_manager", "name": "audit_project_manager",
"version": "1.0.2", "version": "1.0.4",
"build": "abc", "build": "abc",
"description": "审计项目管理", "description": "审计项目管理",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
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,
...@@ -33,4 +35,8 @@ export async function getInitialState() { ...@@ -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 { 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 { 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 RecordWorkbench from '@/components/record-workbench';
import { DataTablePrimaryText } from '@/components/DataTable'; import { DataTablePrimaryText } from '@/components/DataTable';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
...@@ -46,10 +47,34 @@ const FIELD_TYPES = [ ...@@ -46,10 +47,34 @@ const FIELD_TYPES = [
const FIELD_TYPE_LABELS = Object.fromEntries(FIELD_TYPES); const FIELD_TYPE_LABELS = Object.fromEntries(FIELD_TYPES);
function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }) { 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 ( return (
<AlertDialog.Root> <AlertDialog.Root>
<AlertDialog.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <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.Container placement="center" size="md">
<AlertDialog.Dialog className="bg-background"> <AlertDialog.Dialog className="bg-background">
<AlertDialog.Header> <AlertDialog.Header>
...@@ -58,8 +83,8 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title } ...@@ -58,8 +83,8 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }
</AlertDialog.Header> </AlertDialog.Header>
<AlertDialog.Body>{description}</AlertDialog.Body> <AlertDialog.Body>{description}</AlertDialog.Body>
<AlertDialog.Footer> <AlertDialog.Footer>
<Button variant="secondary" onPress={onCancel}>取消</Button> <Button isDisabled={deleting} variant="secondary" onPress={onCancel}>取消</Button>
<Button variant="danger" onPress={onConfirm}>确认删除</Button> <Button isDisabled={deleting} isPending={deleting} variant="danger" onPress={confirmDelete}>确认删除</Button>
</AlertDialog.Footer> </AlertDialog.Footer>
</AlertDialog.Dialog> </AlertDialog.Dialog>
</AlertDialog.Container> </AlertDialog.Container>
...@@ -71,14 +96,14 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title } ...@@ -71,14 +96,14 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }
function FormDialog({ open, onClose, onSaved, form }) { function FormDialog({ open, onClose, onSaved, form }) {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState();
useEffect(() => { useEffect(() => {
setError('');
if (open) { if (open) {
setTitle(form?.title || ''); setTitle(form?.title || '');
setError('');
} }
}, [open, form]); }, [open, form, setError]);
const save = async () => { const save = async () => {
if (!title.trim()) { if (!title.trim()) {
...@@ -123,7 +148,6 @@ function FormDialog({ open, onClose, onSaved, form }) { ...@@ -123,7 +148,6 @@ function FormDialog({ open, onClose, onSaved, form }) {
<Label>表单标题</Label> <Label>表单标题</Label>
<Input value={title} variant="secondary" onChange={(event) => setTitle(event.target.value)} /> <Input value={title} variant="secondary" onChange={(event) => setTitle(event.target.value)} />
</TextField> </TextField>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
</div> </div>
</XinyuanModal> </XinyuanModal>
); );
...@@ -140,9 +164,10 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) { ...@@ -140,9 +164,10 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
settings: '{}', settings: '{}',
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState();
useEffect(() => { useEffect(() => {
setError('');
if (!open) { if (!open) {
return; return;
} }
...@@ -155,8 +180,7 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) { ...@@ -155,8 +180,7 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
primary: Boolean(field?.primary), primary: Boolean(field?.primary),
settings: JSON.stringify(field?.settings || {}, null, 2), settings: JSON.stringify(field?.settings || {}, null, 2),
}); });
setError(''); }, [open, field, setError]);
}, [open, field]);
const save = async () => { const save = async () => {
if (!values.label.trim() || !values.name.trim()) { if (!values.label.trim() || !values.name.trim()) {
...@@ -243,7 +267,6 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) { ...@@ -243,7 +267,6 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
<Label>字段设置 JSON</Label> <Label>字段设置 JSON</Label>
<TextArea fullWidth rows={9} variant="secondary" value={values.settings} onChange={(event) => setValues((current) => ({ ...current, settings: event.target.value }))} /> <TextArea fullWidth rows={9} variant="secondary" value={values.settings} onChange={(event) => setValues((current) => ({ ...current, settings: event.target.value }))} />
</TextField> </TextField>
{error ? <Alert className="sm:col-span-2" status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content></Alert> : null}
</div> </div>
</XinyuanModal> </XinyuanModal>
); );
...@@ -252,8 +275,9 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) { ...@@ -252,8 +275,9 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
function FieldsDialog({ form, open, onClose }) { function FieldsDialog({ form, open, onClose }) {
const [formDetail, setFormDetail] = useState(null); const [formDetail, setFormDetail] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [success, setSuccess] = useState(''); const [, setError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [editingField, setEditingField] = useState(null); const [editingField, setEditingField] = useState(null);
const [fieldDialogOpen, setFieldDialogOpen] = useState(false); const [fieldDialogOpen, setFieldDialogOpen] = useState(false);
...@@ -263,18 +287,23 @@ function FieldsDialog({ form, open, onClose }) { ...@@ -263,18 +287,23 @@ function FieldsDialog({ form, open, onClose }) {
const reload = useCallback(async () => { const reload = useCallback(async () => {
if (!form?.name || !open) { if (!form?.name || !open) {
setError('');
setSuccess('');
return; return;
} }
setLoading(true); setLoading(true);
setLoadFailed(false);
setError(''); setError('');
try { try {
setFormDetail(await getDFormDetail(form.name)); setFormDetail(await getDFormDetail(form.name));
} catch (requestError) { } catch (requestError) {
setLoadFailed(true);
setFormDetail(null);
setError(requestError?.message || '字段列表加载失败。'); setError(requestError?.message || '字段列表加载失败。');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [form?.name, open]); }, [form?.name, open, setError, setSuccess]);
useEffect(() => { useEffect(() => {
reload(); reload();
...@@ -403,7 +432,7 @@ function FieldsDialog({ form, open, onClose }) { ...@@ -403,7 +432,7 @@ function FieldsDialog({ form, open, onClose }) {
{ key: 'delete', label: '删除', icon: Delete, tone: 'danger', onClick: ({ record }) => setDeleteField(record.raw) }, { key: 'delete', label: '删除', icon: Delete, tone: 'danger', onClick: ({ record }) => setDeleteField(record.raw) },
]} ]}
collapseRowActions={false} collapseRowActions={false}
states={{ loading, error, success, emptyTitle: '暂无字段', emptyDescription: '使用“新建字段”配置表单结构。' }} states={{ loading, loadFailed, onRetry: reload, emptyTitle: '暂无字段', emptyDescription: '使用“新建字段”配置表单结构。' }}
/> />
<XinyuanModal <XinyuanModal
...@@ -450,8 +479,9 @@ function FieldsDialog({ form, open, onClose }) { ...@@ -450,8 +479,9 @@ function FieldsDialog({ form, open, onClose }) {
export default function DynamicFormManager() { export default function DynamicFormManager() {
const [forms, setForms] = useState([]); const [forms, setForms] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [success, setSuccess] = useState(''); const [, setError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(20); const [pageSize, setPageSize] = useState(20);
...@@ -462,17 +492,19 @@ export default function DynamicFormManager() { ...@@ -462,17 +492,19 @@ export default function DynamicFormManager() {
const reload = useCallback(async () => { const reload = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError(''); setError('');
try { try {
const result = await queryDForm(0, 9999, {}, null, 'created_at'); const result = await queryDForm(0, 9999, {}, null, 'created_at');
setForms(result?.forms || []); setForms(result?.forms || []);
} catch (requestError) { } catch (requestError) {
setLoadFailed(true);
setForms([]); setForms([]);
setError(requestError?.message || '项目表单加载失败。'); setError(requestError?.message || '项目表单加载失败。');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [setError]);
useEffect(() => { useEffect(() => {
reload(); reload();
...@@ -564,7 +596,7 @@ export default function DynamicFormManager() { ...@@ -564,7 +596,7 @@ export default function DynamicFormManager() {
setPage(0); setPage(0);
}, },
}} }}
states={{ loading, error, success, emptyTitle: '暂无项目表单', emptyDescription: '使用“新建表单”开始配置。' }} states={{ loading, loadFailed, onRetry: reload, emptyTitle: '暂无项目表单', emptyDescription: '使用“新建表单”开始配置。' }}
/> />
<FormDialog <FormDialog
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
Alert,
AlertDialog, AlertDialog,
Button, Button,
Disclosure, Disclosure,
...@@ -117,7 +117,7 @@ function useFormDefinition(instance) { ...@@ -117,7 +117,7 @@ function useFormDefinition(instance) {
const identifier = getFormIdentifier(instance?.form); const identifier = getFormIdentifier(instance?.form);
const [definition, setDefinition] = useState(null); const [definition, setDefinition] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -152,7 +152,7 @@ function useFormDefinition(instance) { ...@@ -152,7 +152,7 @@ function useFormDefinition(instance) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [identifier]); }, [identifier, setError]);
return { definition, loading, error }; return { definition, loading, error };
} }
...@@ -239,14 +239,14 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) { ...@@ -239,14 +239,14 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) {
function FormRowDialog({ definition, isOpen, row, onClose, onSave }) { function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
const [values, setValues] = useState({}); const [values, setValues] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState();
useEffect(() => { useEffect(() => {
setError('');
if (isOpen) { if (isOpen) {
setValues(row && typeof row === 'object' ? row : {}); setValues(row && typeof row === 'object' ? row : {});
setError('');
} }
}, [isOpen, row]); }, [isOpen, row, setError]);
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
...@@ -294,11 +294,6 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) { ...@@ -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> </div>
</XinyuanModal> </XinyuanModal>
); );
...@@ -546,7 +541,7 @@ export default function DynamicFormValueEditor({ ...@@ -546,7 +541,7 @@ export default function DynamicFormValueEditor({
const instances = useMemo(() => normalizeInstances(value, multiple), [value, multiple]); const instances = useMemo(() => normalizeInstances(value, multiple), [value, multiple]);
const [forms, setForms] = useState([]); const [forms, setForms] = useState([]);
const [formsLoading, setFormsLoading] = useState(false); const [formsLoading, setFormsLoading] = useState(false);
const [formsError, setFormsError] = useState(''); const [formsError, setFormsError] = useFeedbackState();
const [activeGroup, setActiveGroup] = useState(DEFAULT_GROUP); const [activeGroup, setActiveGroup] = useState(DEFAULT_GROUP);
const [newGroupName, setNewGroupName] = useState(''); const [newGroupName, setNewGroupName] = useState('');
const [isGroupDialogOpen, setGroupDialogOpen] = useState(false); const [isGroupDialogOpen, setGroupDialogOpen] = useState(false);
...@@ -564,7 +559,10 @@ export default function DynamicFormValueEditor({ ...@@ -564,7 +559,10 @@ export default function DynamicFormValueEditor({
); );
useEffect(() => { useEffect(() => {
if (!canEdit) return undefined; if (!canEdit) {
setFormsError('');
return undefined;
}
let cancelled = false; let cancelled = false;
setFormsLoading(true); setFormsLoading(true);
setFormsError(''); setFormsError('');
...@@ -581,7 +579,7 @@ export default function DynamicFormValueEditor({ ...@@ -581,7 +579,7 @@ export default function DynamicFormValueEditor({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [canEdit]); }, [canEdit, setFormsError]);
const groups = useMemo(() => { const groups = useMemo(() => {
const configured = Array.from(new Set( const configured = Array.from(new Set(
...@@ -770,9 +768,7 @@ export default function DynamicFormValueEditor({ ...@@ -770,9 +768,7 @@ export default function DynamicFormValueEditor({
<Disclosure.Body> <Disclosure.Body>
<div className="space-y-4"> <div className="space-y-4">
{formsError ? ( {formsError ? (
<Alert status="danger"> <p className="text-sm text-danger" role="alert">{formsError}</p>
<Alert.Content><Alert.Description>{formsError}</Alert.Description></Alert.Content>
</Alert>
) : null} ) : null}
{multiple ? ( {multiple ? (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import clsx from 'clsx'; 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 ReferenceSearchSelect from '@/components/ReferenceSearchSelect';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
import FormDatePicker from '@/components/FormDatePicker'; import FormDatePicker from '@/components/FormDatePicker';
...@@ -191,7 +192,7 @@ function JsonInput({ value, onChange, placeholder }) { ...@@ -191,7 +192,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();
const handleUpload = async (files) => { const handleUpload = async (files) => {
const file = files?.[0]; const file = files?.[0];
...@@ -240,14 +241,14 @@ function FileInput({ fieldDef, value, onChange }) { ...@@ -240,14 +241,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();
const [reloadKey, setReloadKey] = useState(0);
const subjectName = getReferenceSubjectName(fieldDef); const subjectName = getReferenceSubjectName(fieldDef);
useEffect(() => { useEffect(() => {
...@@ -257,6 +258,8 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -257,6 +258,8 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return undefined; return undefined;
} }
setNestedSubject(null);
setError('');
loadNestedSubjectDefinition(subjectName) loadNestedSubjectDefinition(subjectName)
.then((result) => { .then((result) => {
if (!cancelled) { if (!cancelled) {
...@@ -273,7 +276,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -273,7 +276,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [subjectName, depth]); }, [subjectName, depth, reloadKey, setError]);
if (!subjectName || depth >= MAX_SUBJECT_DEPTH) { if (!subjectName || depth >= MAX_SUBJECT_DEPTH) {
const fieldLabel = fieldDef?.label || fieldDef?.name || '字段'; const fieldLabel = fieldDef?.label || fieldDef?.name || '字段';
...@@ -287,7 +290,12 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) { ...@@ -287,7 +290,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">
<p className="text-sm text-danger" role="alert">{error}</p>
<Button size="sm" variant="secondary" onPress={() => setReloadKey((current) => current + 1)}>重新加载</Button>
</div>
);
} }
if (!nestedSubject) { if (!nestedSubject) {
...@@ -592,7 +600,7 @@ export default function DynamicRecordDialog({ ...@@ -592,7 +600,7 @@ export default function DynamicRecordDialog({
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [values, setValues] = useState({}); const [values, setValues] = useState({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState();
const editing = Boolean(record); const editing = Boolean(record);
const hideTitle = shouldHideViewTitleColumn(view); const hideTitle = shouldHideViewTitleColumn(view);
const dialogColumns = useMemo( const dialogColumns = useMemo(
...@@ -625,6 +633,7 @@ export default function DynamicRecordDialog({ ...@@ -625,6 +633,7 @@ export default function DynamicRecordDialog({
}, [subject, record, restrictionDefaults]); }, [subject, record, restrictionDefaults]);
useEffect(() => { useEffect(() => {
setError('');
if (!open) { if (!open) {
return; return;
} }
...@@ -632,8 +641,7 @@ export default function DynamicRecordDialog({ ...@@ -632,8 +641,7 @@ export default function DynamicRecordDialog({
!editing ? restrictionDefaults.title ?? '' : '' !editing ? restrictionDefaults.title ?? '' : ''
)); ));
setValues(initialValues); setValues(initialValues);
setError(''); }, [open, record, initialValues, editing, restrictionDefaults, setError]);
}, [open, record, initialValues, editing, restrictionDefaults]);
const handleSubmit = async () => { const handleSubmit = async () => {
setSaving(true); setSaving(true);
...@@ -730,9 +738,6 @@ export default function DynamicRecordDialog({ ...@@ -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> </div>
</XinyuanModal> </XinyuanModal>
); );
......
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';
...@@ -310,7 +311,7 @@ export default function ReferenceSearchSelect({ ...@@ -310,7 +311,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('warning');
const [active, setActive] = useState(false); const [active, setActive] = useState(false);
const lastSearchRequestId = useRef(0); const lastSearchRequestId = useRef(0);
...@@ -477,7 +478,7 @@ export default function ReferenceSearchSelect({ ...@@ -477,7 +478,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 { AlertDialog, Button } from '@heroui/react'; import { AlertDialog, Button } from '@heroui/react';
import RecordWorkbench from '@/components/record-workbench'; import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import useFeedbackState from '@/hooks/useFeedbackState';
import { import {
batchRemoveRecords, batchRemoveRecords,
cloneRecord, cloneRecord,
...@@ -128,9 +129,12 @@ export default function SubjectRecordWorkbench({ ...@@ -128,9 +129,12 @@ export default function SubjectRecordWorkbench({
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [subject, setSubject] = useState(null); const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]); const [views, setViews] = useState([]);
const [displayConfig, setDisplayConfig] = useState({});
const [, setDisplayConfigError] = useFeedbackState('warning');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [success, setSuccess] = useState(''); const [, setError] = useFeedbackState();
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({});
...@@ -148,6 +152,8 @@ export default function SubjectRecordWorkbench({ ...@@ -148,6 +152,8 @@ export default function SubjectRecordWorkbench({
const requestId = requestSequence.current + 1; const requestId = requestSequence.current + 1;
requestSequence.current = requestId; requestSequence.current = requestId;
setLoading(true); setLoading(true);
setLoadFailed(false);
setDisplayConfigError('');
setError(''); setError('');
if (!keepFeedback) { if (!keepFeedback) {
setSuccess(''); setSuccess('');
...@@ -165,19 +171,23 @@ export default function SubjectRecordWorkbench({ ...@@ -165,19 +171,23 @@ export default function SubjectRecordWorkbench({
setTotalRecords(resources.totalCount || 0); setTotalRecords(resources.totalCount || 0);
setSubject(resources.subject || null); setSubject(resources.subject || null);
setViews(resources.views || []); setViews(resources.views || []);
setDisplayConfig(resources.displayConfig || {});
setDisplayConfigError(resources.displayConfigError || '');
} catch (requestError) { } catch (requestError) {
if (requestId !== requestSequence.current) return; if (requestId !== requestSequence.current) return;
setLoadFailed(true);
setRecords([]); setRecords([]);
setTotalRecords(0); setTotalRecords(0);
setSubject(null); setSubject(null);
setViews([]); setViews([]);
setDisplayConfig({});
setError(requestError?.message || '列表加载失败,请稍后重试。'); setError(requestError?.message || '列表加载失败,请稍后重试。');
} finally { } finally {
if (requestId === requestSequence.current) { if (requestId === requestSequence.current) {
setLoading(false); setLoading(false);
} }
} }
}, [activeViewName, filters, page, pageSize, query, subjectName]); }, [activeViewName, filters, page, pageSize, query, subjectName, setDisplayConfigError, setError, setSuccess]);
useEffect(() => { useEffect(() => {
reload(); reload();
...@@ -213,8 +223,8 @@ export default function SubjectRecordWorkbench({ ...@@ -213,8 +223,8 @@ export default function SubjectRecordWorkbench({
); );
const hasAccessibleView = Boolean(activeView); const hasAccessibleView = Boolean(activeView);
const configuredView = useMemo( const configuredView = useMemo(
() => buildConfiguredWorkbenchView(records, {}, activeView, subject), () => buildConfiguredWorkbenchView(records, {}, activeView, subject, displayConfig),
[records, activeView, subject], [records, activeView, subject, displayConfig],
); );
const filteredRecords = configuredView.filteredRecords || []; const filteredRecords = configuredView.filteredRecords || [];
const visibleColumns = useMemo(() => { const visibleColumns = useMemo(() => {
...@@ -248,7 +258,7 @@ export default function SubjectRecordWorkbench({ ...@@ -248,7 +258,7 @@ export default function SubjectRecordWorkbench({
setSuccess, setSuccess,
subject, subject,
subjectName, subjectName,
}), [reload, subject, subjectName]); }), [reload, setError, setSuccess, subject, subjectName]);
const wrapAction = (action) => ({ const wrapAction = (action) => ({
...action, ...action,
...@@ -423,8 +433,8 @@ export default function SubjectRecordWorkbench({ ...@@ -423,8 +433,8 @@ export default function SubjectRecordWorkbench({
} : null} } : null}
states={{ states={{
loading, loading,
error, loadFailed,
success, onRetry: reload,
loadingTitle: `正在加载${title}`, loadingTitle: `正在加载${title}`,
loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。', loadingDescription: '正在读取对象结构、视图配置和记录数据,请稍候。',
emptyTitle: hasAccessibleView ? '当前视图暂无数据' : '暂无可访问的视图', 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 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';
...@@ -21,11 +22,13 @@ export default function UserSearchSelect({ ...@@ -21,11 +22,13 @@ export default function UserSearchSelect({
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [profiles, setProfiles] = useState({}); const [profiles, setProfiles] = useState({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]); const selectedIds = useMemo(() => toArray(value).map(getUserId).filter(Boolean), [value]);
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: [
...@@ -42,14 +45,17 @@ export default function UserSearchSelect({ ...@@ -42,14 +45,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((id) => !profiles[id]); const missing = selectedIds.filter((id) => !profiles[id]);
...@@ -81,6 +87,7 @@ export default function UserSearchSelect({ ...@@ -81,6 +87,7 @@ export default function UserSearchSelect({
return ( return (
<XinyuanAutocomplete <XinyuanAutocomplete
aria-label={ariaLabel} aria-label={ariaLabel}
errorText={error}
inputValue={keyword} inputValue={keyword}
isDisabled={disabled} isDisabled={disabled}
isClearable isClearable
......
...@@ -4,9 +4,11 @@ import { ...@@ -4,9 +4,11 @@ import {
DataTablePersonIdentity, DataTablePersonIdentity,
DataTablePrimaryAction, DataTablePrimaryAction,
DataTableText, DataTableText,
EmptyStateSurface,
isDataTableLongTextField, isDataTableLongTextField,
} from '@/components/DataTable'; } from '@/components/DataTable';
import { KPI } from '@heroui-pro/react/kpi'; import { KPI } from '@heroui-pro/react/kpi';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { import {
TableActionButton, TableActionButton,
TableActionGroup, TableActionGroup,
...@@ -27,9 +29,10 @@ import FilterSelect from '@/components/FilterSelect'; ...@@ -27,9 +29,10 @@ import FilterSelect from '@/components/FilterSelect';
import FormDatePicker from '@/components/FormDatePicker'; import FormDatePicker from '@/components/FormDatePicker';
import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect'; import ReferenceSearchSelect from '@/pages/RecordDetailDrawer/components/ReferenceSearchSelect';
import UserSearchSelect from '@/components/UserSearchSelect'; import UserSearchSelect from '@/components/UserSearchSelect';
import TableFieldValue from '@/components/TableFieldValue';
import { getTableFieldDisplayConfig } from '@/utils/tableFieldDisplay';
import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView'; import { formatFieldValue, getFieldValue, normalizeText } from '@/utils/subjectView';
import { import {
Alert,
Avatar, Avatar,
Button, Button,
Chip, Chip,
...@@ -42,7 +45,6 @@ import { ...@@ -42,7 +45,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';
...@@ -330,6 +332,9 @@ function getFilterPlaceholder(field) { ...@@ -330,6 +332,9 @@ function getFilterPlaceholder(field) {
function defaultRenderCell(record, column) { function defaultRenderCell(record, column) {
const value = getRecordFieldValue(record, column.field); const value = getRecordFieldValue(record, column.field);
if (getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue value={value} column={column} />;
}
const text = formatFieldValue(value, column.fieldDef) || '-'; const text = formatFieldValue(value, column.fieldDef) || '-';
if (column.fieldDef?.type === 'user') { if (column.fieldDef?.type === 'user') {
...@@ -575,13 +580,7 @@ export default function RecordWorkbench({ ...@@ -575,13 +580,7 @@ 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 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],
...@@ -922,6 +921,9 @@ export default function RecordWorkbench({ ...@@ -922,6 +921,9 @@ export default function RecordWorkbench({
if (columnIndex === 0 && detailPanel) { if (columnIndex === 0 && detailPanel) {
const value = getRecordFieldValue(row.record, column.field); 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) || '-'; const text = formatFieldValue(value, column.fieldDef) || '-';
return ( return (
<DataTablePrimaryAction <DataTablePrimaryAction
...@@ -1216,47 +1218,55 @@ export default function RecordWorkbench({ ...@@ -1216,47 +1218,55 @@ export default function RecordWorkbench({
) : null} ) : null}
</div> </div>
{error ? ( {!loading && states?.loadFailed ? (
<Alert status="danger"> <EmptyStateSurface>
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content> <EmptyState className="min-h-64" size="md">
</Alert> <EmptyState.Header>
) : null} <EmptyState.Title>{states.errorTitle || '数据加载失败'}</EmptyState.Title>
<EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
<div ref={tableScrollRef} className={tableContentClass}> </EmptyState.Header>
<DataTable {states.onRetry ? (
ariaLabel={title || '业务数据列表'} <EmptyState.Content><Button variant="secondary" onPress={() => states.onRetry()}>重新加载</Button></EmptyState.Content>
className={stickyTable ? 'rc-data-table-sticky' : ''} ) : null}
columns={dataGridColumns} </EmptyState>
rows={dataGridRows} </EmptyStateSurface>
isLoading={loading} ) : (
getRowKey={(row) => row.key} <div ref={tableScrollRef} className={tableContentClass}>
selectedRowKeys={selectable ? selectedIds : undefined} <DataTable
onSelectedRowKeysChange={ ariaLabel={title || '业务数据列表'}
selectable className={stickyTable ? 'rc-data-table-sticky' : ''}
? (keys) => columns={dataGridColumns}
onSelectionChange?.( rows={dataGridRows}
keys.filter((key) => currentPageRecordIds.includes(key)), isLoading={loading}
) getRowKey={(row) => row.key}
: undefined selectedRowKeys={selectable ? selectedIds : undefined}
} onSelectedRowKeysChange={
disabledRowKeys={disabledDataGridRowKeys} selectable
emptyTitle={states?.emptyTitle || '暂无数据'} ? (keys) =>
emptyDescription={states?.emptyDescription} onSelectionChange?.(
renderSelectionActions={() => ( keys.filter((key) => currentPageRecordIds.includes(key)),
<> )
<span className="text-sm">已选择记录</span> : undefined
{visibleBulkActions.map((action) => ( }
<BulkActionButton disabledRowKeys={disabledDataGridRowKeys}
key={action.key} emptyTitle={states?.emptyTitle || '暂无数据'}
action={action} emptyDescription={states?.emptyDescription}
context={toolbarContext} renderSelectionActions={() => (
/> <>
))} <span className="text-sm">已选择记录</span>
</> {visibleBulkActions.map((action) => (
)} <BulkActionButton
/> key={action.key}
<WorkbenchPagination pagination={pagination} /> action={action}
</div> context={toolbarContext}
/>
))}
</>
)}
/>
<WorkbenchPagination pagination={pagination} />
</div>
)}
</section> </section>
{drawerRecordName && detailPanel?.render {drawerRecordName && detailPanel?.render
......
...@@ -2,7 +2,7 @@ export const APPLICATION_DISPLAY_NAME = '审计项目管理'; ...@@ -2,7 +2,7 @@ export const APPLICATION_DISPLAY_NAME = '审计项目管理';
export const APPLICATION_NAME = 'audit_project_manager_new'; export const APPLICATION_NAME = 'audit_project_manager_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 SOURCE_APPLICATION_NAME = APPLICATION_NAME; export const SOURCE_APPLICATION_NAME = APPLICATION_DISPLAY_NAME;
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
'/audit_project_table/dashboard': '驾驶舱', '/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 { ...@@ -7,7 +7,8 @@ import {
import authService from '@/services/authService'; import authService from '@/services/authService';
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 useFeedbackState from '@/hooks/useFeedbackState';
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';
...@@ -55,7 +56,7 @@ export default function LoginPage() { ...@@ -55,7 +56,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();
const redirect = normalizeRedirect(searchParams.get('redirect')); const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
...@@ -205,13 +206,6 @@ export default function LoginPage() { ...@@ -205,13 +206,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
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
DataTable, DataTable,
...@@ -168,7 +169,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -168,7 +169,7 @@ 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('warning');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -234,7 +235,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -234,7 +235,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [pageSize, populatedRecords, referenceIds, subjectName]); }, [pageSize, populatedRecords, referenceIds, subjectName, setError]);
if (referenceIds.length === 0) { if (referenceIds.length === 0) {
return <ReferenceRecordsFrame count={0}><DataTableEmptyState title="暂无关联记录" /></ReferenceRecordsFrame>; return <ReferenceRecordsFrame count={0}><DataTableEmptyState title="暂无关联记录" /></ReferenceRecordsFrame>;
...@@ -267,11 +268,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 ...@@ -267,11 +268,7 @@ 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"> <p className="text-sm text-danger" role="alert">{error}</p>
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null} ) : null}
<ReferenceRecordsFrame count={referenceIds.length}> <ReferenceRecordsFrame count={referenceIds.length}>
{records.length === 0 ? ( {records.length === 0 ? (
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
Alert,
Button, Button,
Card, Card,
Checkbox, Checkbox,
...@@ -69,11 +69,7 @@ function FieldError({ children, className }) { ...@@ -69,11 +69,7 @@ function FieldError({ children, className }) {
if (!children) return null; if (!children) return null;
return ( return (
<Alert className={className} status="danger"> <p className={clsx('text-sm text-danger', className)} role="alert">{children}</p>
<Alert.Content>
<Alert.Description>{children}</Alert.Description>
</Alert.Content>
</Alert>
); );
} }
...@@ -178,6 +174,8 @@ function FieldValue({ value, fieldDef, plainNested = false }) { ...@@ -178,6 +174,8 @@ function FieldValue({ value, fieldDef, plainNested = false }) {
function UserSelector({ value, onChange, multiple, placeholder = '请选择用户', variant }) { function UserSelector({ value, onChange, multiple, placeholder = '请选择用户', variant }) {
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [options, setOptions] = useState([]); const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useFeedbackState();
const selectedIds = useMemo( const selectedIds = useMemo(
() => toArray(value).map((item) => item?._id || item).filter(Boolean), () => toArray(value).map((item) => item?._id || item).filter(Boolean),
[value], [value],
...@@ -185,6 +183,8 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用 ...@@ -185,6 +183,8 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true);
setError('');
const loadOptions = async () => { const loadOptions = async () => {
const filter = keyword.trim() const filter = keyword.trim()
...@@ -201,10 +201,13 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用 ...@@ -201,10 +201,13 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
if (!cancelled) { if (!cancelled) {
setOptions(result?.users || []); setOptions(result?.users || []);
} }
} catch (error) { } catch (requestError) {
if (!cancelled) { if (!cancelled) {
setOptions([]); setOptions([]);
setError(requestError?.message || '用户列表加载失败,请重新搜索。');
} }
} finally {
if (!cancelled) setLoading(false);
} }
}; };
...@@ -213,7 +216,7 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用 ...@@ -213,7 +216,7 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [keyword]); }, [keyword, setError]);
const handleSelectionChange = (keys) => { const handleSelectionChange = (keys) => {
if (!multiple) { if (!multiple) {
...@@ -243,8 +246,10 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用 ...@@ -243,8 +246,10 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
<XinyuanAutocomplete <XinyuanAutocomplete
{...selectionProps} {...selectionProps}
aria-label="用户" aria-label="用户"
emptyText="暂无用户" emptyText={loading ? '正在加载用户...' : '暂无用户'}
errorText={error}
inputValue={keyword} inputValue={keyword}
isLoading={loading}
options={autocompleteOptions} options={autocompleteOptions}
placeholder={placeholder} placeholder={placeholder}
searchPlaceholder="搜索用户名或显示名" searchPlaceholder="搜索用户名或显示名"
...@@ -315,6 +320,7 @@ function ControlledFieldEditor({ ...@@ -315,6 +320,7 @@ function ControlledFieldEditor({
useAuditMaterialCenter, useAuditMaterialCenter,
}) { }) {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [, setRequestError] = useFeedbackState();
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);
...@@ -329,6 +335,7 @@ function ControlledFieldEditor({ ...@@ -329,6 +335,7 @@ function ControlledFieldEditor({
setUploading(true); setUploading(true);
setError(''); setError('');
setRequestError('');
onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId); onValidationChange?.(fieldPath, '附件正在上传,请稍候。', editSessionId);
try { try {
...@@ -337,6 +344,7 @@ function ControlledFieldEditor({ ...@@ -337,6 +344,7 @@ function ControlledFieldEditor({
onChange?.(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue); onChange?.(fieldDef?.multiple ? [...toArray(value), fileValue] : fileValue);
} catch (uploadError) { } catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。'); setError(uploadError?.message || '附件上传失败,请稍后重试。');
setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally { } finally {
setUploading(false); setUploading(false);
onValidationChange?.(fieldPath, '', editSessionId); onValidationChange?.(fieldPath, '', editSessionId);
...@@ -490,6 +498,7 @@ function ControlledFieldEditor({ ...@@ -490,6 +498,7 @@ function ControlledFieldEditor({
if (!nextValue.trim()) { if (!nextValue.trim()) {
setError(''); setError('');
setRequestError('');
onValidationChange?.(fieldPath, '', editSessionId); onValidationChange?.(fieldPath, '', editSessionId);
return; return;
} }
...@@ -497,6 +506,7 @@ function ControlledFieldEditor({ ...@@ -497,6 +506,7 @@ function ControlledFieldEditor({
try { try {
JSON.parse(nextValue); JSON.parse(nextValue);
setError(''); setError('');
setRequestError('');
onValidationChange?.(fieldPath, '', editSessionId); onValidationChange?.(fieldPath, '', editSessionId);
} catch (parseError) { } catch (parseError) {
const message = 'JSON 格式不正确,请检查后再保存。'; const message = 'JSON 格式不正确,请检查后再保存。';
...@@ -569,6 +579,7 @@ export default function TailwindEditableField({ ...@@ -569,6 +579,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();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
useEffect(() => { useEffect(() => {
...@@ -616,6 +627,7 @@ export default function TailwindEditableField({ ...@@ -616,6 +627,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 {
...@@ -631,6 +643,7 @@ export default function TailwindEditableField({ ...@@ -631,6 +643,7 @@ export default function TailwindEditableField({
setEditing(false); setEditing(false);
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '保存失败,请稍后重试。'); setError(saveError?.message || '保存失败,请稍后重试。');
setRequestError(saveError?.message || '保存失败,请稍后重试。');
} }
}; };
...@@ -643,6 +656,7 @@ export default function TailwindEditableField({ ...@@ -643,6 +656,7 @@ export default function TailwindEditableField({
const cancel = () => { const cancel = () => {
setDraft(value); setDraft(value);
setError(''); setError('');
setRequestError('');
setEditing(false); setEditing(false);
}; };
...@@ -654,6 +668,7 @@ export default function TailwindEditableField({ ...@@ -654,6 +668,7 @@ export default function TailwindEditableField({
setUploading(true); setUploading(true);
setError(''); setError('');
setRequestError('');
try { try {
const uploaded = await uploadFile(file); const uploaded = await uploadFile(file);
...@@ -664,6 +679,7 @@ export default function TailwindEditableField({ ...@@ -664,6 +679,7 @@ export default function TailwindEditableField({
setEditing(false); setEditing(false);
} catch (uploadError) { } catch (uploadError) {
setError(uploadError?.message || '附件上传失败,请稍后重试。'); setError(uploadError?.message || '附件上传失败,请稍后重试。');
setRequestError(uploadError?.message || '附件上传失败,请稍后重试。');
} finally { } finally {
setUploading(false); setUploading(false);
} }
...@@ -671,6 +687,7 @@ export default function TailwindEditableField({ ...@@ -671,6 +687,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)
...@@ -685,6 +702,7 @@ export default function TailwindEditableField({ ...@@ -685,6 +702,7 @@ export default function TailwindEditableField({
} catch (saveError) { } catch (saveError) {
setDraft(draft); setDraft(draft);
setError(saveError?.message || '删除附件失败,请稍后重试。'); 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();
const requestIdRef = useRef(0); const requestIdRef = useRef(0);
useEffect(() => { useEffect(() => {
...@@ -85,6 +86,7 @@ export function useSubjectRecord( ...@@ -85,6 +86,7 @@ export function useSubjectRecord(
const loadAll = async () => { const loadAll = async () => {
if (!recordName) { if (!recordName) {
setError('');
setRemoteRecord(null); setRemoteRecord(null);
setEditableRecord(null); setEditableRecord(null);
setSubject(null); setSubject(null);
...@@ -146,7 +148,7 @@ export function useSubjectRecord( ...@@ -146,7 +148,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 { Close, Fullscreen, FullscreenExit } from '@/components/AppIcons'; 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, { import React, {
useCallback, useCallback,
useContext, useContext,
...@@ -104,8 +106,10 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -104,8 +106,10 @@ 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();
const [actionError, setActionError] = useState(''); const [, setSaveSuccess] = useFeedbackState('success');
const [, setSuccess] = useFeedbackState('success');
const [, setActionError] = useFeedbackState();
const [actionLoadingKey, setActionLoadingKey] = useState(''); const [actionLoadingKey, setActionLoadingKey] = useState('');
const [lastTabSwitchRevision, setLastTabSwitchRevision] = useState(''); const [lastTabSwitchRevision, setLastTabSwitchRevision] = useState('');
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
...@@ -129,10 +133,11 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -129,10 +133,11 @@ const RecordDetailDrawer = (props, ref) => {
); );
useEffect(() => { useEffect(() => {
setActionError('');
setSuccess('');
if (open) { if (open) {
setRefreshKey(Date.now()); setRefreshKey(Date.now());
setIsFullscreen(false); setIsFullscreen(false);
setActionError('');
setActionLoadingKey(''); setActionLoadingKey('');
} }
...@@ -142,28 +147,11 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -142,28 +147,11 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
setLastTabSwitchRevision(''); setLastTabSwitchRevision('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
editSessionRef.current += 1; editSessionRef.current += 1;
}, [open, recordName]); }, [open, recordName, setActionError, setSaveError, setSaveSuccess, setSuccess]);
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]);
useEffect(() => { useEffect(() => {
if (!isEditing) { if (!isEditing) {
...@@ -313,7 +301,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -313,7 +301,7 @@ const RecordDetailDrawer = (props, ref) => {
try { try {
await action.onClick(actionContext); await action.onClick(actionContext);
if (action.successMessage) { if (action.successMessage) {
toast.success(action.successMessage, { timeout: 4000 }); setSuccess(action.successMessage);
} }
if (action.refreshAfter) { if (action.refreshAfter) {
refreshRecord({ selectLastTab: action.selectLastTab }); refreshRecord({ selectLastTab: action.selectLastTab });
...@@ -348,6 +336,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -348,6 +336,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
}; };
...@@ -364,6 +353,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -364,6 +353,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields({}); setDirtyFields({});
setValidationErrors({}); setValidationErrors({});
setSaveError(''); setSaveError('');
setSaveSuccess('');
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
setIsEditing(true); setIsEditing(true);
}; };
...@@ -441,6 +431,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -441,6 +431,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)
...@@ -469,6 +460,7 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -469,6 +460,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 {
...@@ -578,7 +570,17 @@ const RecordDetailDrawer = (props, ref) => { ...@@ -578,7 +570,17 @@ const RecordDetailDrawer = (props, ref) => {
<div className="flex min-h-48 items-center justify-center" role="status"> <div className="flex min-h-48 items-center justify-center" role="status">
<Spinner size="sm" /> <Spinner size="sm" />
</div> </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> <div>
{isRecordLocked ? ( {isRecordLocked ? (
<Alert status="warning"> <Alert status="warning">
......
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;
...@@ -351,6 +351,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -351,6 +351,7 @@ export default function ApplicationSwitcherDialog({
}, [fallbackApplications]); }, [fallbackApplications]);
useEffect(() => { useEffect(() => {
setErrorMessage('');
if (!open) { if (!open) {
setApplicationSearch(''); setApplicationSearch('');
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>
...@@ -444,12 +445,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -444,12 +445,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 />
......
import useFeedbackState from '@/hooks/useFeedbackState';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { EmptyStateSurface } from '@/components/DataTable'; import { EmptyStateSurface } from '@/components/DataTable';
import XinyuanModal from '@/components/XinyuanModal'; import XinyuanModal from '@/components/XinyuanModal';
...@@ -11,7 +12,7 @@ import { ...@@ -11,7 +12,7 @@ import {
FolderOpenIcon, FolderOpenIcon,
ChartPieIcon, ChartPieIcon,
} from '@/components/AppIcons'; } 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 { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi'; import { KPI } from '@heroui-pro/react/kpi';
import { ListView } from '@heroui-pro/react/list-view'; import { ListView } from '@heroui-pro/react/list-view';
...@@ -299,7 +300,7 @@ function ProjectListDialog({ group, onClose, onOpenRecord }) { ...@@ -299,7 +300,7 @@ function ProjectListDialog({ group, onClose, onOpenRecord }) {
export default function AuditProjectDashboardPage() { export default function AuditProjectDashboardPage() {
const [records, setRecords] = useState([]); const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState();
const [activeYear, setActiveYear] = useState('all'); const [activeYear, setActiveYear] = useState('all');
const [selectedRecordName, setSelectedRecordName] = useState(''); const [selectedRecordName, setSelectedRecordName] = useState('');
const [projectGroup, setProjectGroup] = useState(null); const [projectGroup, setProjectGroup] = useState(null);
...@@ -315,7 +316,7 @@ export default function AuditProjectDashboardPage() { ...@@ -315,7 +316,7 @@ export default function AuditProjectDashboardPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [setError]);
useEffect(() => { loadDashboard(); }, [loadDashboard]); useEffect(() => { loadDashboard(); }, [loadDashboard]);
...@@ -379,23 +380,26 @@ export default function AuditProjectDashboardPage() { ...@@ -379,23 +380,26 @@ export default function AuditProjectDashboardPage() {
const openRecord = (record) => setSelectedRecordName(record?.name || record?._id || ''); const openRecord = (record) => setSelectedRecordName(record?.name || record?._id || '');
const openGroup = (title, groupRecords) => setProjectGroup({ title, records: groupRecords }); 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>; return <div className="flex min-h-72 items-center justify-center"><Spinner aria-label="正在加载审计项目驾驶舱" /></div>;
} }
if (error) { if (error && !selectedRecordName) {
return ( return (
<Alert status="danger"> <EmptyStateSurface>
<Alert.Content> <EmptyState className="min-h-64" size="md">
<Alert.Description>{error}</Alert.Description> <EmptyState.Header>
<Button className="mt-3" variant="secondary" onPress={loadDashboard}>重新加载</Button> <EmptyState.Title>驾驶舱数据加载失败</EmptyState.Title>
</Alert.Content> <EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
</Alert> </EmptyState.Header>
<EmptyState.Content><Button variant="secondary" onPress={loadDashboard}>重新加载</Button></EmptyState.Content>
</EmptyState>
</EmptyStateSurface>
); );
} }
return ( return (
<div className="rc-page-stack"> <div aria-busy={loading} className="rc-page-stack">
<div className="flex justify-end"> <div className="flex justify-end">
<XinyuanAutocomplete <XinyuanAutocomplete
aria-label="统计年份" aria-label="统计年份"
......
...@@ -2,6 +2,7 @@ import RecordWorkbench from '@/components/record-workbench'; ...@@ -2,6 +2,7 @@ import RecordWorkbench from '@/components/record-workbench';
import RecordDetailDrawer from '@/pages/RecordDetailDrawer'; import RecordDetailDrawer from '@/pages/RecordDetailDrawer';
import { getSubjectViewDetail, loadRecord, loadSubject } from '@/services/DataService'; import { getSubjectViewDetail, loadRecord, loadSubject } from '@/services/DataService';
import { useUserInfo } from '@/hooks/user'; import { useUserInfo } from '@/hooks/user';
import useFeedbackState from '@/hooks/useFeedbackState';
import { buildViewColumns } from '@/utils/subjectView'; import { buildViewColumns } from '@/utils/subjectView';
import { CheckCircleIcon } from '@/components/AppIcons'; import { CheckCircleIcon } from '@/components/AppIcons';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
...@@ -24,8 +25,9 @@ export default function AuditProjectFormPage() { ...@@ -24,8 +25,9 @@ export default function AuditProjectFormPage() {
const [view, setView] = useState(null); const [view, setView] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState(''); const [loadFailed, setLoadFailed] = useState(false);
const [success, setSuccess] = useState(''); const [, setError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const queryParameters = useMemo(() => { const queryParameters = useMemo(() => {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
...@@ -49,6 +51,10 @@ export default function AuditProjectFormPage() { ...@@ -49,6 +51,10 @@ export default function AuditProjectFormPage() {
restrictionValues, restrictionValues,
} = queryParameters; } = queryParameters;
if (!subjectName || !viewName) { if (!subjectName || !viewName) {
setSubject(null);
setView(null);
setRecords([]);
setLoadFailed(true);
setLoading(false); setLoading(false);
setError('链接缺少 subject 或 view 参数,无法加载审计项目填写表。'); setError('链接缺少 subject 或 view 参数,无法加载审计项目填写表。');
return; return;
...@@ -63,6 +69,7 @@ export default function AuditProjectFormPage() { ...@@ -63,6 +69,7 @@ export default function AuditProjectFormPage() {
: {}; : {};
setLoading(true); setLoading(true);
setLoadFailed(false);
setError(''); setError('');
try { try {
const [subjectData, viewData, recordData] = await Promise.all([ const [subjectData, viewData, recordData] = await Promise.all([
...@@ -80,6 +87,7 @@ export default function AuditProjectFormPage() { ...@@ -80,6 +87,7 @@ export default function AuditProjectFormPage() {
setView(viewData); setView(viewData);
setRecords(scopedRecords.map(normalizeWorkbenchRecord)); setRecords(scopedRecords.map(normalizeWorkbenchRecord));
} catch (requestError) { } catch (requestError) {
setLoadFailed(true);
setSubject(null); setSubject(null);
setView(null); setView(null);
setRecords([]); setRecords([]);
...@@ -87,7 +95,7 @@ export default function AuditProjectFormPage() { ...@@ -87,7 +95,7 @@ export default function AuditProjectFormPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [queryParameters]); }, [queryParameters, setError]);
useEffect(() => { useEffect(() => {
document.title = '审计项目填写表'; document.title = '审计项目填写表';
...@@ -159,8 +167,8 @@ export default function AuditProjectFormPage() { ...@@ -159,8 +167,8 @@ export default function AuditProjectFormPage() {
}} }}
states={{ states={{
loading, loading,
error, loadFailed,
success, onRetry: loadPage,
loadingTitle: '正在加载审计项目填写表', loadingTitle: '正在加载审计项目填写表',
loadingDescription: '正在读取视图、字段结构和受限记录。', loadingDescription: '正在读取视图、字段结构和受限记录。',
emptyTitle: '当前链接范围暂无记录', emptyTitle: '当前链接范围暂无记录',
......
...@@ -14,6 +14,8 @@ import { ...@@ -14,6 +14,8 @@ import {
toArray, toArray,
} from '@/utils/subjectView'; } from '@/utils/subjectView';
import { loadApplicationTableDisplayConfig } from '@/services/tableFieldDisplayService';
const QUERY_PAGE_SIZE = 1000; const QUERY_PAGE_SIZE = 1000;
const DEFAULT_RECORD_SORT = '-updated_at -created_at'; const DEFAULT_RECORD_SORT = '-updated_at -created_at';
const RECORD_LEVEL_FIELDS = new Set([ const RECORD_LEVEL_FIELDS = new Set([
...@@ -276,9 +278,10 @@ export async function loadSubjectWorkbenchResources( ...@@ -276,9 +278,10 @@ export async function loadSubjectWorkbenchResources(
query = {}, query = {},
{ viewName = '', filters = {}, page = 0, pageSize = 20 } = {}, { viewName = '', filters = {}, page = 0, pageSize = 20 } = {},
) { ) {
const [subject, views] = await Promise.all([ const [subject, views, tableDisplayConfig] = await Promise.all([
loadSubject(subjectName), loadSubject(subjectName),
getSubjectView(subjectName), getSubjectView(subjectName),
loadApplicationTableDisplayConfig(subjectName),
]); ]);
const accessibleGridViews = buildGridViews(views || []); const accessibleGridViews = buildGridViews(views || []);
const activeView = accessibleGridViews.find((view) => view.name === viewName) const activeView = accessibleGridViews.find((view) => view.name === viewName)
...@@ -301,6 +304,7 @@ export async function loadSubjectWorkbenchResources( ...@@ -301,6 +304,7 @@ export async function loadSubjectWorkbenchResources(
totalCount: pageResult.totalCount, totalCount: pageResult.totalCount,
subject, subject,
views: views || [], views: views || [],
...tableDisplayConfig,
}; };
} }
...@@ -316,10 +320,10 @@ export function buildGridViews(views = []) { ...@@ -316,10 +320,10 @@ export function buildGridViews(views = []) {
return isViewAccessible(fallbackView) ? [fallbackView] : []; return isViewAccessible(fallbackView) ? [fallbackView] : [];
} }
export function buildConfiguredWorkbenchView(records, _filters, view, subject) { export function buildConfiguredWorkbenchView(records, _filters, view, subject, displayConfig = {}) {
if (!view) return { columns: [], filteredRecords: [] }; if (!view) return { columns: [], filteredRecords: [] };
const columns = buildViewColumns(view, subject); const columns = buildViewColumns(view, subject, { displayConfig });
const filteredRecords = records.filter((record) => { const filteredRecords = records.filter((record) => {
const rawRecord = record.raw || record; const rawRecord = record.raw || record;
......
...@@ -5,7 +5,8 @@ import { ...@@ -5,7 +5,8 @@ import {
queryAuditMaterials, queryAuditMaterials,
uploadAuditProjectFile, uploadAuditProjectFile,
} from '../auditProjectMaterialService'; } 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'; import { useEffect, useMemo, useState } from 'react';
const MATERIAL_CENTER_TAB = 'material-center'; const MATERIAL_CENTER_TAB = 'material-center';
...@@ -44,10 +45,10 @@ export default function AuditProjectMaterialField({ ...@@ -44,10 +45,10 @@ export default function AuditProjectMaterialField({
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();
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();
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)),
...@@ -84,7 +85,7 @@ export default function AuditProjectMaterialField({ ...@@ -84,7 +85,7 @@ export default function AuditProjectMaterialField({
}) })
.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,
...@@ -243,9 +244,10 @@ export default function AuditProjectMaterialField({ ...@@ -243,9 +244,10 @@ export default function AuditProjectMaterialField({
)} )}
/> />
{materialsError ? ( {materialsError ? (
<Alert status="danger"><Alert.Content><Alert.Description>{materialsError}</Alert.Description></Alert.Content> <div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}><ArrowPathIcon size={14} />重试</Button> <p className="text-sm text-danger" role="alert">{materialsError}</p>
</Alert> <Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}><ArrowPathIcon size={14} />重试</Button>
</div>
) : null} ) : null}
</div> </div>
</Tabs.Panel> </Tabs.Panel>
...@@ -260,7 +262,6 @@ export default function AuditProjectMaterialField({ ...@@ -260,7 +262,6 @@ export default function AuditProjectMaterialField({
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>
......
import useFeedbackState from '@/hooks/useFeedbackState';
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination, EmptyStateSurface } from '@/components/DataTable'; import { DataTablePagination, EmptyStateSurface } 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,
...@@ -11,7 +11,6 @@ import { ...@@ -11,7 +11,6 @@ import {
SearchField, SearchField,
Spinner, Spinner,
Tabs, Tabs,
toast,
} from '@heroui/react'; } from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react'; import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
...@@ -130,7 +129,9 @@ export default function NotificationListPage() { ...@@ -130,7 +129,9 @@ 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();
const [, setOperationError] = useFeedbackState();
const [, setSuccess] = useFeedbackState('success');
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -162,7 +163,7 @@ export default function NotificationListPage() { ...@@ -162,7 +163,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;
...@@ -175,18 +176,20 @@ export default function NotificationListPage() { ...@@ -175,18 +176,20 @@ export default function NotificationListPage() {
const refresh = useCallback(() => setReloadKey((value) => value + 1), []); const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const runItemOperation = useCallback(async (key, operation, successMessage) => { const runItemOperation = useCallback(async (key, operation, successMessage) => {
setBusyKey(key); setBusyKey(key);
setOperationError('');
setSuccess('');
try { try {
await operation(); await operation();
toast.success(successMessage, { timeout: 3000 }); setSuccess(successMessage);
refresh(); refresh();
return true; return true;
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); setOperationError(operationError?.message || '操作失败,请稍后重试');
return false; return false;
} finally { } finally {
setBusyKey(''); setBusyKey('');
} }
}, [refresh]); }, [refresh, setOperationError, setSuccess]);
const handleMarkRead = useCallback((notification) => runItemOperation( const handleMarkRead = useCallback((notification) => runItemOperation(
notification.name, notification.name,
...@@ -203,6 +206,7 @@ export default function NotificationListPage() { ...@@ -203,6 +206,7 @@ export default function NotificationListPage() {
), [runItemOperation]); ), [runItemOperation]);
const handleOpenTarget = useCallback(async (notification, target) => { const handleOpenTarget = useCallback(async (notification, target) => {
setBusyKey(notification.name); setBusyKey(notification.name);
setOperationError('');
try { try {
if (!isNotificationRead(notification, userInfo?._id)) { if (!isNotificationRead(notification, userInfo?._id)) {
await markNotificationRead(notification.name); await markNotificationRead(notification.name);
...@@ -211,11 +215,11 @@ export default function NotificationListPage() { ...@@ -211,11 +215,11 @@ export default function NotificationListPage() {
else history.push(target.href); else history.push(target.href);
refresh(); refresh();
} catch (operationError) { } catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 }); setOperationError(operationError?.message || '无法打开通知关联流程');
} finally { } finally {
setBusyKey(''); setBusyKey('');
} }
}, [refresh, userInfo?._id]); }, [refresh, setOperationError, userInfo?._id]);
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
...@@ -253,10 +257,16 @@ export default function NotificationListPage() { ...@@ -253,10 +257,16 @@ export default function NotificationListPage() {
</Tabs> </Tabs>
</div> </div>
{error ? ( {!loading && error ? (
<Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content> <EmptyStateSurface>
<Button size="sm" variant="ghost" onPress={refresh}>重试</Button> <EmptyState className="min-h-64" size="md">
</Alert> <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} ) : 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>
......
...@@ -50,6 +50,48 @@ ...@@ -50,6 +50,48 @@
8. 列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。 8. 列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。
9. 业务列表、动态表单数据表和关联记录表中的长文本最多展示两行,内容始终限制在所属列内;超出内容通过 HeroUI Tooltip 或项目详情查看,避免遮挡相邻列或由单条长文本过度拉高整行。弹窗内只读关联记录表按实际数据行高度收缩,不提供无业务价值的列宽拖拽。 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. 状态流转 ## 3. 状态流转
```text ```text
...@@ -141,7 +183,7 @@ Ant Design Icons。 ...@@ -141,7 +183,7 @@ Ant Design Icons。
模板标识,也不继续展示缺少定义的表格;编辑态可以重新选择有效模板,现有数据不会自动改写。 模板标识,也不继续展示缺少定义的表格;编辑态可以重新选择有效模板,现有数据不会自动改写。
- 模板存在但缺少字段定义时显示“暂无可展示字段”,不使用已有数据对象的内部 key 临时生成 - 模板存在但缺少字段定义时显示“暂无可展示字段”,不使用已有数据对象的内部 key 临时生成
英文表头;现有行数据保持原样,待模板字段定义恢复后再按业务标签展示。 英文表头;现有行数据保持原样,待模板字段定义恢复后再按业务标签展示。
- 业务列表中的 `form` 字段在单值时只显示表单名称;多值时显示“表单数量(表单实例数)”摘要标签,不直接展示 `{ form, data, settings }` JSON,也不展开多个表单实例或表单名称。 - 未配置表格字段显示模式或阈值时,业务列表中的 `form` 字段在单值时只显示表单名称;多值时显示“表单数量(表单实例数)”摘要标签,不直接展示 `{ form, data, settings }` JSON,也不展开多个表单实例或表单名称。
- 删除为不可恢复操作,执行前必须二次确认;接口失败时保留当前页面并展示错误。 - 删除为不可恢复操作,执行前必须二次确认;接口失败时保留当前页面并展示错误。
## 9. 权限与限制 ## 9. 权限与限制
...@@ -178,6 +220,35 @@ Ant Design Icons。 ...@@ -178,6 +220,35 @@ Ant Design Icons。
远程搜索;选择后只把 `{name,type,size,url}` 快照写入业务草稿。 远程搜索;选择后只把 `{name,type,size,url}` 快照写入业务草稿。
- 本地文件先调用 `/upload` 并进入当前详情草稿。取消编辑不创建材料主档;单值替换,多值追加并 - 本地文件先调用 `/upload` 并进入当前详情草稿。取消编辑不创建材料主档;单值替换,多值追加并
按 URL 去重,移除或清空业务附件不删除材料中心主档或底层文件。 按 URL 去重,移除或清空业务附件不删除材料中心主档或底层文件。
- 用户点击详情“保存”后,系统才用 `source_app = "audit_project_manager_new"`、当前用户、字段快照和 - 用户点击详情“保存”后,系统才用当前应用中文名称 `source_app = "审计项目管理"`(统一读取
`APPLICATION_DISPLAY_NAME`)、当前用户、字段快照和
ISO 时间逐个创建 `am_material`。全部建档成功后才调用既有业务记录更新回调;部分失败会在当前 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 @@ ...@@ -44,6 +44,19 @@
- `404` `{"code":404,"message":"record '<name>' not found"}` - `404` `{"code":404,"message":"record '<name>' not found"}`
- `400` 业务校验失败(缺少必填字段、主键冲突、类型不匹配等) - `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 核心模型字段 ## 2. Record 核心模型字段
不同 `subject` 通过 `metadata` 扩展业务字段。 不同 `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 { ...@@ -31,6 +31,18 @@ html {
color-scheme: light !important; 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 { body {
margin: 0; margin: 0;
font-family: var(--rc-font-sans); font-family: var(--rc-font-sans);
......
...@@ -291,9 +291,10 @@ export function shouldHideViewTitleColumn(view) { ...@@ -291,9 +291,10 @@ export function shouldHideViewTitleColumn(view) {
return view?.settings?.shortTitle === true; 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 configuredColumns = view?.settings?.columns || [];
const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view); const hideTitleColumn = !includeHidden && shouldHideViewTitleColumn(view);
const viewDisplayConfig = { ...displayConfig, ...view?.settings?.displayConfig };
if (configuredColumns.length > 0) { if (configuredColumns.length > 0) {
return configuredColumns return configuredColumns
...@@ -310,6 +311,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {}) ...@@ -310,6 +311,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
(column.field === 'title' ? '标题' : '其他信息'); (column.field === 'title' ? '标题' : '其他信息');
return { return {
...column, ...column,
displayConfig: { ...viewDisplayConfig, ...column.displayConfig },
width: column.width || 180, width: column.width || 180,
label, label,
fieldDef: fieldDef || { name: fieldName, label, type: 'text' }, fieldDef: fieldDef || { name: fieldName, label, type: 'text' },
...@@ -321,6 +323,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {}) ...@@ -321,6 +323,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
if (!hideTitleColumn) { if (!hideTitleColumn) {
defaultColumns.push({ defaultColumns.push({
field: 'title', field: 'title',
displayConfig: viewDisplayConfig,
width: 180, width: 180,
label: '标题', label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' }, fieldDef: { name: 'title', label: '标题', type: 'text' },
...@@ -335,6 +338,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {}) ...@@ -335,6 +338,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
'其他信息'; '其他信息';
defaultColumns.push({ defaultColumns.push({
field: fieldPath, field: fieldPath,
displayConfig: viewDisplayConfig,
width: 180, width: 180,
label, label,
fieldDef: field, 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 { getSystemInfo } from '@/services/DataService';
import { Spinner } from '@heroui/react'; import { Spinner } from '@heroui/react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
...@@ -19,7 +20,7 @@ export default function SiteWrapper() { ...@@ -19,7 +20,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();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -55,7 +56,7 @@ export default function SiteWrapper() { ...@@ -55,7 +56,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 { 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