Commit b2ccff62 authored by drigle's avatar drigle

fix: unify operation feedback and refine dashboard distribution

parent 31252990
......@@ -50,9 +50,18 @@
- 预览态普通标量字段使用无边框静态值:标签为 `text-muted`,值为 `text-foreground`,空值显示“暂无内容”;不得用灰色 disabled Input/Autocomplete 冒充预览。编辑态才显示 HeroUI `secondary` 控件;权限禁用、锁定或只读字段在同一网格中继续显示静态只读值。
- 预览与编辑共用两列字段网格和字段顺序;长文本、附件等内容跨两列。编辑态使用 Footer 的 HeroUI `secondary` 取消 + `primary` 保存,保存只提交一次,取消丢弃草稿,不改变原有校验、权限和请求时机。
- 预览态 Footer 使用 HeroUI `primary` 编辑按钮作为主操作;点击后在同一个 Modal 和编辑会话内切换为编辑态,保留当前记录标题、字段草稿和材料草稿,不关闭后重新打开;进入编辑态后显示 `secondary` 取消 + `primary` 保存,取消关闭并丢弃未保存草稿。
- 例行成功反馈使用顶部居中的自动消失 Toast;需要处理的错误保留上下文 Alert。
- 接口成功、失败及降级警告统一使用应用根部 `AppFeedback` 的 HeroUI Toast,不在页面或弹窗正文渲染接口反馈横条。表单校验摘要、字段错误及操作说明保留在对应上下文。
- 应用切换严格继承 `model_analysis`:从 `/info -> site.group` 动态生成分组,保持配置顺序并显示真实数量;未归组应用进入“其他应用”,无配置时使用“全部应用”。Modal 固定 46rem/42rem、28px inset、手机一列、`sm` 起两列,间距 12px;加载骨架使用相同网格,桌面不得切成三列,长列表仅在 Modal Body 内滚动。
## 操作反馈浮层
- 参考问题整改提交 `513706c`、`bfc5862`:根部唯一的 `Toast.Provider` 覆盖登录页、站点配置、AppShell 及业务弹窗;通过 Portal 在视口顶部居中呈现,最多展示 3 条。
- 成功默认 4 秒消失,错误及警告默认 8 秒消失;悬停或键盘聚焦时暂停计时。关闭按钮使用 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`)和 `AppIcons.Close`,位于正文右侧并参与布局,通过官方 `slot="close"` 支持点击和键盘关闭,提供“关闭提示”可访问名称;前排按钮无需悬停即可操作,后排堆叠提示隐藏关闭按钮以避免误关闭。
- `src/design-system/feedback.css` 仅调整浮层层级(300,高于业务 Modal 和移动侧栏)、安全区位置和后排关闭按钮可见性;不再放大默认 Toast 关闭按钮或设置绝对定位、正文右侧占位。颜色、边框、圆角、阴影和 Hover/焦点样式继续使用 HeroUI 主题。
- `useFeedbackState` 让同一来源的新提示替换旧提示,相同错误再次发生时重新显示;清空反馈或来源卸载时清理对应提示。关闭 Toast 不清除请求失败状态或表单草稿,不触发写入操作。
- 查询失败保留失败空状态与只读重试入口,避免将接口错误显示为“暂无数据”;部分统计失败时保留已成功返回的数据与重新加载入口。
- 台账详情的保存成功提示由页面级独立来源维护,材料建档及记录写入全部成功才触发;编辑保存显示“详情已保存”。提示在关闭详情弹窗及刷新列表后保留至自动消失或手动关闭,开启下一次新建、预览、编辑或再次保存时清理。
## 字段后缀图标
- Autocomplete 下拉箭头与 DatePicker 日历图标使用 16px Ant Design 图标;可选的选择清除图标与日期清除图标使用统一的 14px Ant Design `Close` 图标。页容量选择器按上一条规则关闭清除图标。
......
{
"name": "incorrupt_party_build",
"version": "1.0.3",
"version": "1.0.4",
"build": "abc",
"description": "党风廉政建设",
"date": "2025-07-14 19:27:20",
......
import '@/tailwind.css';
import '@/global.less';
import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
export async function getInitialState() {
return {
......@@ -8,4 +10,8 @@ export async function getInitialState() {
};
}
export default defineApp({});
export function rootContainer(container) {
return createElement(AppFeedback, null, container);
}
export default defineApp({ rootContainer });
import { Button, Toast } from '@heroui/react';
import { Close } from '@/components/AppIcons';
export default function AppFeedback({ children }) {
return (
<>
{children}
<Toast.Provider
aria-label="操作提示"
className="app-feedback-region"
maxVisibleToasts={3}
placement="top"
width="min(30rem, calc(100vw - 2rem))"
>
{({ toast }) => (
<Toast className="app-feedback-toast gap-2" toast={toast} variant={toast.content.variant}>
<Toast.Indicator variant={toast.content.variant} />
<Toast.Content className="min-w-0">
<Toast.Title>{toast.content.title}</Toast.Title>
{toast.content.description ? (
<Toast.Description className="max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground">
{toast.content.description}
</Toast.Description>
) : null}
{toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null}
</Toast.Content>
<Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast>
)}
</Toast.Provider>
</>
);
}
import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import { SiteContext } from '@/wrapper/Site';
......@@ -47,7 +46,6 @@ export default function AppShell({
userInfo={sidebarProps.userInfo}
onOpenChange={setIsApplicationDialogOpen}
/>
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider>
);
}
/* Keep operation feedback above Modal and the mobile sidebar, outside page scrolling. */
.app-feedback-region.toast-region {
z-index: 300;
top: max(1rem, env(safe-area-inset-top));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
visibility: hidden;
pointer-events: none;
}
import { toast } from '@heroui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
const FEEDBACK_OPTIONS = {
error: { title: '操作失败', variant: 'danger', timeout: 8000 },
success: { title: '操作成功', variant: 'success', timeout: 4000 },
warning: { title: '请注意', variant: 'warning', timeout: 8000 },
};
// Dismissing feedback must not clear the request's failure state or the form draft.
export default function useFeedbackState(tone = 'error') {
const [message, setMessage] = useState('');
const toastKeyRef = useRef(null);
const isMountedRef = useRef(true);
const updateMessage = useCallback((nextMessage) => {
if (!isMountedRef.current) return;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
setMessage(nextMessage);
if (!nextMessage) return;
const { title, variant, timeout } = FEEDBACK_OPTIONS[tone] || FEEDBACK_OPTIONS.error;
// Every attempt reports feedback, including identical errors on a retry.
toastKeyRef.current = toast(title, { description: nextMessage, variant, timeout });
}, [tone]);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (toastKeyRef.current) toast.close(toastKeyRef.current);
toastKeyRef.current = null;
};
}, []);
return [message, updateMessage];
}
import xyLogoRed from '@/assets/identity/xy-logo-red.png';
import useFeedbackState from '@/hooks/useFeedbackState';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_BASE_PATH, APP_NAME } from '@/config/ApplicationConfig';
import authService from '@/services/authService';
import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site';
import {
Alert,
Button,
Card,
Input,
......@@ -55,7 +55,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [, setError] = useFeedbackState('error');
const redirect = normalizeRedirect(searchParams.get('redirect'));
......@@ -209,14 +209,6 @@ export default function LoginPage() {
</div>
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button
fullWidth
className="login-submit-button"
......
......@@ -5,12 +5,12 @@ import {
} from '@/config/ApplicationConfig';
import { getApplications } from '@/services/DataService';
import { EmptyStateSurface } from '@/components/DataTable';
import { ExclamationTriangleIcon, MagnifyingGlassIcon } from '@/components/AppIcons';
import { MagnifyingGlassIcon } from '@/components/AppIcons';
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { ItemCard } from '@heroui-pro/react/item-card';
import { ItemCardGroup } from '@heroui-pro/react/item-card-group';
import {
Alert,
Avatar,
Chip,
Modal,
......@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const [, setErrorMessage] = useFeedbackState('warning');
const [applicationSearch, setApplicationSearch] = useState('');
const currentApplicationName = useMemo(() => {
return applications.find(isCurrentApplication)?.name || CURRENT_APPLICATION_NAME;
......@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => {
if (!isOpen) {
setApplicationSearch('');
setErrorMessage('');
return;
}
......@@ -392,7 +393,7 @@ export default function ApplicationSwitcherDialog({
return () => {
cancelled = true;
};
}, [fallbackApplications, isOpen]);
}, [fallbackApplications, isOpen, setErrorMessage]);
return (
<Modal.Root>
......@@ -443,13 +444,6 @@ export default function ApplicationSwitcherDialog({
offset={12}
size={24}
>
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? (
......
......@@ -130,6 +130,18 @@
5. 前端逐条调用 `PUT /record/:name`
6. 附件、长文本主要内容等字段一期不参与批量修改
### 4.9 操作提示与失败重试
- 功能入口:登录、站点配置、应用切换、6 类台账的查询/保存/删除/Excel 导入导出/批量修改、详情材料搜索与上传、驾驶舱和通知中心。
- 接口成功、失败及降级警告统一显示根部 HeroUI Toast,Portal 在视口顶部展示,层级高于详情弹窗、删除确认框和移动侧栏;页面滚动或弹窗打开时也可看到并关闭。
- 成功提示默认 4 秒、错误和警告默认 8 秒后消失;悬停或键盘聚焦暂停计时。当前提示右侧的关闭图标始终可见,使用 HeroUI 小号透明图标按钮,通过 Toast 的 `close` slot 支持点击或键盘关闭;后排堆叠提示隐藏关闭按钮以避免误关闭。同一来源的新提示替换旧提示,相同错误再次发生时重新弹出;清空反馈或离开对应页面时清理来源提示。
- 关闭提示仅影响展示,不改变请求结果、字段校验、表单输入、附件草稿、选中记录或分页筛选,不重新执行保存/删除/导入/批量修改/通知操作。必填校验摘要和字段错误仍保留在表单内,并定位第一个错误字段;批量修改和删除确认说明保留。
- 台账与通知查询失败时显示失败空状态及“重新加载/重试”,详细原因由 Toast 展示,关闭提示后不会误显示“暂无数据”。台账重试沿用当前分类、页码、页容量及页面筛选;通知重试沿用分类、已读状态、关键词和页码。重试仅重新读取资源,不重放写入操作。
- 驾驶舱全部查询失败时显示失败空状态和重试;部分失败时保留成功统计,标明统计可能不完整并提供重新加载入口。
- 材料查询失败时保留选择器内的加载错误与“重新加载材料”;上传错误单独提示,不影响材料搜索状态。多附件上传部分失败时保留已成功上传的附件草稿,提示已上传数量和未完成原因,保存详情时仍按原流程创建 `am_material`。
- 保存失败保留详情弹窗和草稿;Excel 导入、批量修改及批量删除部分失败时使用 warning 展示成功/失败条数。站点配置与应用列表降级使用 warning 并沿用原有回退信息。
- 台账详情点击“保存”后,只有待建档材料和记录创建/更新全部成功才显示独立的成功提示:编辑显示“详情已保存”,新建显示记录创建结果。沿用保存成功后关闭详情弹窗并刷新列表的交互;提示来源保留在台账页面,不随弹窗关闭或列表重载被清除,默认 4 秒消失且可手动关闭。重新开启新建、预览、编辑或再次保存时清理上一条成功提示;校验未通过、正在保存或写入失败时不显示成功提示,失败保留草稿和错误反馈。
## 5. 字段与数据规则
- 日期字段页面和 Excel 统一展示为 `YYYY-MM-DD`
......@@ -146,8 +158,8 @@
## 6. 异常处理与限制
- 保存失败时保留当前表单输入,弹窗保持打开,并在弹窗内提示用户;必填校验同时标记对应字段并定位到第一个错误字段
- Excel 导入、批量修改和删除确认在弹窗内失败时,错误信息在当前弹窗内展示,不要求用户先关闭弹窗
- 保存失败时保留当前表单输入,弹窗保持打开,通过高于弹窗的 Toast 提示用户;必填校验仍在弹窗内标记对应字段并定位到第一个错误字段
- Excel 导入、批量修改和删除确认在弹窗内失败时,通过 Toast 展示可关闭的错误提示,不要求用户先关闭弹窗
- Excel 解析失败时不发起创建请求
- Excel 批量导入单条失败不影响其它条目继续导入
- 批量删除和单条删除均需要二次确认
......
import { ArrowPathIcon, Delete } from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import { Alert, Button, Label, Tabs, Tooltip } from '@heroui/react';
import { Button, Label, Tabs, Tooltip } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import { useEffect, useMemo, useState } from 'react';
import { queryAuditMaterials, uploadIntegrityPartyFile } from '../integrityPartyMaterialService';
......@@ -16,19 +17,20 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [materialsError, setMaterialsError] = useFeedbackState('error');
const [, setUploadError] = useFeedbackState('error');
const [retry, setRetry] = useState(0);
const [localKeys, setLocalKeys] = useState(() => new Set());
const files = useMemo(() => (multiple ? toFiles(value) : toFiles(value).slice(0, 1)), [multiple, value]);
useEffect(() => { setTab('material-center'); setKeyword(''); setDebouncedKeyword(''); setError(''); setLocalKeys(new Set()); }, [editSessionId]);
useEffect(() => { setTab('material-center'); setKeyword(''); setDebouncedKeyword(''); setMaterialsError(''); setUploadError(''); setLocalKeys(new Set()); }, [editSessionId, setMaterialsError, setUploadError]);
useEffect(() => { const timer = window.setTimeout(() => setDebouncedKeyword(keyword), 300); return () => window.clearTimeout(timer); }, [keyword]);
useEffect(() => {
if (tab !== 'material-center') return undefined;
let cancelled = false; setLoading(true); setError('');
queryAuditMaterials(debouncedKeyword).then((items) => { if (!cancelled) setMaterials(items); }).catch((requestError) => { if (!cancelled) { setMaterials([]); setError(requestError?.message || '审计材料加载失败,请稍后重试。'); } }).finally(() => { if (!cancelled) setLoading(false); });
let cancelled = false; setLoading(true); setMaterialsError('');
queryAuditMaterials(debouncedKeyword).then((items) => { if (!cancelled) setMaterials(items); }).catch((requestError) => { if (!cancelled) { setMaterials([]); setMaterialsError(requestError?.message || '审计材料加载失败,请稍后重试。'); } }).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [debouncedKeyword, retry, tab]);
}, [debouncedKeyword, retry, tab, setMaterialsError]);
function selectMaterial(key) {
const material = materials.find((item) => String(item.id) === String(key || ''));
......@@ -41,16 +43,22 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
async function uploadLocal(selected) {
const inputFiles = multiple ? toFiles(selected) : toFiles(selected).slice(0, 1);
if (inputFiles.length === 0) return;
setUploading(true); setError('');
setUploading(true); setUploadError('');
const uploaded = [];
try {
for (const file of inputFiles) uploaded.push(await uploadIntegrityPartyFile(file));
} catch (uploadError) {
const message = uploadError?.message || '附件上传失败,请稍后重试。';
setUploadError(uploaded.length > 0 ? `已上传 ${uploaded.length} 个附件,其余附件未完成上传:${message}` : message);
} finally {
if (uploaded.length > 0) {
const pending = uploaded.map((asset) => ({ clientId: fileKey(asset), fileAsset: asset, materialRecordId: null }));
setLocalKeys((current) => new Set(multiple ? [...current, ...pending.map((item) => item.clientId)] : pending.map((item) => item.clientId)));
onPendingChange(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending });
onChange(multiple ? uniqueFiles([...files, ...uploaded]) : uploaded[uploaded.length - 1]);
} catch (uploadError) { setError(uploadError?.message || '附件上传失败,请稍后重试。'); }
finally { setUploading(false); }
}
setUploading(false);
}
}
function removeFile(file, index) {
......@@ -87,6 +95,7 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
ariaLabel="选择审计材料"
className="w-full min-w-0"
emptyText="没有找到可选择的审计材料"
errorText={materialsError}
inputValue={keyword}
isDisabled={disabled || uploading}
isLoading={loading}
......@@ -106,16 +115,11 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
</span>
)}
/>
{error ? (
<Alert className="mt-3" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetry((value) => value + 1)}>
{materialsError ? (
<Button className="mt-3" size="sm" variant="secondary" onPress={() => setRetry((value) => value + 1)}>
<ArrowPathIcon size={14} />
重试
重新加载材料
</Button>
</Alert>
) : null}
</Tabs.Panel>
<Tabs.Panel className="mt-3 w-full min-w-0 p-0" id="local-upload">
......@@ -126,13 +130,6 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={uploadLocal}
/>
{error ? (
<Alert className="mt-3" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</Tabs.Panel>
</Tabs>
</div>;
......
......@@ -18,6 +18,7 @@ import { TableActionButton, TableActionGroup } from '@/components/TableActionBut
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
import XinyuanFilterSurface from '@/components/XinyuanFilterSurface';
import XinyuanModal from '@/components/XinyuanModal';
import useFeedbackState from '@/hooks/useFeedbackState';
import {
Alert,
Button,
......@@ -29,7 +30,6 @@ import {
Tabs,
Tooltip,
TextArea,
Toast,
} from '@heroui/react';
import clsx from 'clsx';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
......@@ -440,8 +440,10 @@ export default function IntegrityPartyPage() {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_INTEGRITY_PAGE_SIZE);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const [, setNotice] = useFeedbackState('success');
const [, setWarning] = useFeedbackState('warning');
const [, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [selectedIds, setSelectedIds] = useState([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detailMode, setDetailMode] = useState('create');
......@@ -449,7 +451,11 @@ export default function IntegrityPartyPage() {
const [detailTitle, setDetailTitle] = useState('');
const [detailForm, setDetailForm] = useState(() => createEmptyLedgerForm(activeKey));
const [detailValidationErrors, setDetailValidationErrors] = useState({});
const [dialogErrors, setDialogErrors] = useState({});
const [, setDetailError] = useFeedbackState('error');
const [, setDetailSuccess] = useFeedbackState('success');
const [, setImportError] = useFeedbackState('error');
const [, setBatchError] = useFeedbackState('error');
const [, setDeleteError] = useFeedbackState('error');
const [saving, setSaving] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importFile, setImportFile] = useState(null);
......@@ -469,19 +475,6 @@ export default function IntegrityPartyPage() {
const pendingLocalMaterialsRef = useRef({});
const [detailSessionId, setDetailSessionId] = useState(0);
function setDialogError(dialog, message = '') {
setDialogErrors((current) => ({ ...current, [dialog]: message }));
}
useEffect(() => {
if (!notice) {
return undefined;
}
Toast.toast.success(notice, { timeout: 3600 });
setNotice('');
return undefined;
}, [notice]);
const fieldMap = useMemo(() => {
return activeConfig.fields.reduce((accumulator, field) => {
accumulator[field.name] = field;
......@@ -492,6 +485,12 @@ export default function IntegrityPartyPage() {
() => records.filter((record) => selectedIds.includes(record.id)),
[records, selectedIds],
);
const invalidFieldLabels = activeConfig.fields
.filter((field) => detailValidationErrors[field.name])
.map((field) => field.label);
const detailValidationMessage = invalidFieldLabels.length > 0
? `请填写必填字段:${invalidFieldLabels.join('、')}`
: '';
const activeGridView = gridViews.find((view) => view.name === activeViewName) || gridViews[0] || null;
const detailViews = useMemo(() => allViews.filter((view) => view?.type === 'detail'), [allViews]);
const activeDetailView = detailViews.find((view) => view.name === activeDetailViewName) || detailViews[0] || null;
......@@ -540,6 +539,7 @@ export default function IntegrityPartyPage() {
const requestId = reloadRequestIdRef.current + 1;
reloadRequestIdRef.current = requestId;
setLoading(true);
setLoadFailed(false);
setError('');
const result = await loadLedgerPage(activeKey, {
...nextFilters,
......@@ -554,6 +554,7 @@ export default function IntegrityPartyPage() {
setPage(result.page || 0);
setSelectedIds([]);
if (result.error) {
setLoadFailed(true);
setError(result.error);
}
setLoading(false);
......@@ -586,6 +587,7 @@ export default function IntegrityPartyPage() {
}
function openCreateDialog() {
setDetailSuccess('');
pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1);
setDetailMode('create');
......@@ -593,11 +595,12 @@ export default function IntegrityPartyPage() {
setDetailTitle('');
setDetailForm(createEmptyLedgerForm(activeKey));
setDetailValidationErrors({});
setDialogError('detail');
setDetailError('');
setDetailOpen(true);
}
function openEditDialog(record) {
setDetailSuccess('');
pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1);
setDetailMode('edit');
......@@ -608,11 +611,12 @@ export default function IntegrityPartyPage() {
...record.metadata,
});
setDetailValidationErrors({});
setDialogError('detail');
setDetailError('');
setDetailOpen(true);
}
function openPreviewDialog(record) {
setDetailSuccess('');
pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1);
setDetailMode('view');
......@@ -623,35 +627,30 @@ export default function IntegrityPartyPage() {
...record.metadata,
});
setDetailValidationErrors({});
setDialogError('detail');
setDetailError('');
setDetailOpen(true);
}
function openDeleteDialog(record) {
setActiveRecord(record);
setDialogError('delete');
setDeleteError('');
setDeleteConfirm({ open: true, mode: 'single' });
}
async function saveDetail() {
if (saving) return;
setDetailSuccess('');
const requiredFieldErrors = getRequiredFieldErrors(activeConfig.fields, detailForm);
const invalidFieldNames = Object.keys(requiredFieldErrors);
if (invalidFieldNames.length > 0) {
setDetailValidationErrors(requiredFieldErrors);
setDialogError(
'detail',
`请填写必填字段:${activeConfig.fields
.filter((field) => requiredFieldErrors[field.name])
.map((field) => field.label)
.join('、')}`,
);
focusFirstInvalidField(invalidFieldNames[0]);
return;
}
setSaving(true);
setDetailValidationErrors({});
setDialogError('detail');
setDetailError('');
try {
for (const materials of Object.values(pendingLocalMaterialsRef.current)) {
for (const material of materials) {
......@@ -662,16 +661,16 @@ export default function IntegrityPartyPage() {
}
if (detailMode === 'create') {
const created = await createLedgerRecord(activeKey, { ...detailForm, title: detailTitle });
setNotice(`记录“${created.title}”已创建。`);
setDetailSuccess(`记录“${created.title}”已创建。`);
} else {
const updated = await updateLedgerRecord(activeKey, activeRecord, { ...detailForm, title: detailTitle });
setNotice(`记录“${updated.title}”已保存。`);
await updateLedgerRecord(activeKey, activeRecord, { ...detailForm, title: detailTitle });
setDetailSuccess('详情已保存。');
}
setDetailOpen(false);
pendingLocalMaterialsRef.current = {};
await reload(page);
} catch (saveError) {
setDialogError('detail', saveError?.message || '保存失败,请稍后重试。');
setDetailError(saveError?.message || '保存失败,请稍后重试。');
} finally {
setSaving(false);
}
......@@ -693,7 +692,7 @@ export default function IntegrityPartyPage() {
setDetailOpen(nextOpen);
if (!nextOpen) {
setDetailValidationErrors({});
setDialogError('detail');
setDetailError('');
pendingLocalMaterialsRef.current = {};
setDetailSessionId((value) => value + 1);
}
......@@ -704,22 +703,16 @@ export default function IntegrityPartyPage() {
delete nextValidationErrors[fieldName];
setDetailValidationErrors(nextValidationErrors);
const remainingLabels = activeConfig.fields
.filter((field) => nextValidationErrors[field.name])
.map((field) => field.label);
setDialogError(
'detail',
remainingLabels.length > 0 ? `请填写必填字段:${remainingLabels.join('、')}` : '',
);
setDetailError('');
}
async function confirmDelete() {
setSaving(true);
setDialogError('delete');
setDeleteError('');
try {
if (deleteConfirm.mode === 'batch') {
const result = await deleteLedgerRecords(selectedRecords);
setNotice(
(result.failedCount ? setWarning : setNotice)(
result.failedCount
? `已删除 ${result.deletedCount} 条,${result.failedCount} 条删除失败。`
: `已批量删除 ${result.deletedCount} 条记录。`,
......@@ -732,7 +725,7 @@ export default function IntegrityPartyPage() {
setDeleteConfirm({ open: false, mode: 'single' });
await reload(0);
} catch (deleteError) {
setDialogError('delete', deleteError?.message || '删除失败,请稍后重试。');
setDeleteError(deleteError?.message || '删除失败,请稍后重试。');
} finally {
setSaving(false);
}
......@@ -744,7 +737,7 @@ export default function IntegrityPartyPage() {
setNotice(`导入模板已导出:${result.fileName}`);
} catch (exportError) {
const message = exportError?.message || '模板导出失败,请稍后重试。';
if (errorTarget === 'import') setDialogError('import', message);
if (errorTarget === 'import') setImportError(message);
else setError(message);
}
}
......@@ -774,7 +767,7 @@ export default function IntegrityPartyPage() {
async function handlePreviewImport(file) {
setImportFile(file);
setImportPreview(null);
setDialogError('import');
setImportError('');
if (!file) {
return;
}
......@@ -782,21 +775,21 @@ export default function IntegrityPartyPage() {
const preview = await previewLedgerWorkbook(activeKey, file);
setImportPreview(preview);
} catch (previewError) {
setDialogError('import', previewError?.message || 'Excel 预览失败,请检查文件。');
setImportError(previewError?.message || 'Excel 预览失败,请检查文件。');
}
}
async function handleImportWorkbook() {
if (!importFile) {
setDialogError('import', '请先选择 Excel 文件。');
setImportError('请先选择 Excel 文件。');
return;
}
setImporting(true);
setDialogError('import');
setImportError('');
try {
const result = await importLedgerWorkbook(activeKey, importFile);
setNotice(
(result.failedCount ? setWarning : setNotice)(
result.failedCount
? `已导入 ${result.createdCount} 条,${result.failedCount} 条失败。`
: `已从 ${result.sheetName} 导入 ${result.createdCount} 条记录。`,
......@@ -806,7 +799,7 @@ export default function IntegrityPartyPage() {
setImportPreview(null);
await reload(0);
} catch (importError) {
setDialogError('import', importError?.message || '导入失败,请稍后重试。');
setImportError(importError?.message || '导入失败,请稍后重试。');
} finally {
setImporting(false);
}
......@@ -818,16 +811,16 @@ export default function IntegrityPartyPage() {
return;
}
setBatchForm(createEmptyLedgerForm(activeKey));
setDialogError('batch');
setBatchError('');
setBatchOpen(true);
}
async function handleBatchSave() {
setBatchSaving(true);
setDialogError('batch');
setBatchError('');
try {
const result = await updateLedgerRecords(activeKey, selectedRecords, batchForm);
setNotice(
(result.failedCount ? setWarning : setNotice)(
result.failedCount
? `已批量更新 ${result.updatedCount} 条,${result.failedCount} 条失败。`
: `已批量更新 ${result.updatedCount} 条记录。`,
......@@ -835,7 +828,7 @@ export default function IntegrityPartyPage() {
setBatchOpen(false);
await reload(page);
} catch (batchError) {
setDialogError('batch', batchError?.message || '批量修改失败,请稍后重试。');
setBatchError(batchError?.message || '批量修改失败,请稍后重试。');
} finally {
setBatchSaving(false);
}
......@@ -988,14 +981,6 @@ export default function IntegrityPartyPage() {
</div>
</section>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{gridViews.length > 1 ? (
<Tabs variant="primary" selectedKey={activeViewName} onSelectionChange={(key) => { setActiveViewName(String(key)); setPage(0); setSelectedIds([]); }}>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
......@@ -1119,7 +1104,7 @@ export default function IntegrityPartyPage() {
size="sm"
variant="secondary"
onPress={() => {
setDialogError('import');
setImportError('');
setImportOpen(true);
setImportFile(null);
setImportPreview(null);
......@@ -1137,7 +1122,17 @@ export default function IntegrityPartyPage() {
</div>
</div>
<DataTable
{!loading && loadFailed ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>{activeConfig.label}加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => reload(page)}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
) : <DataTable
ariaLabel={activeConfig.label}
columns={tableColumns}
rows={records}
......@@ -1161,7 +1156,7 @@ export default function IntegrityPartyPage() {
size="sm"
variant="danger-soft"
onPress={() => {
setDialogError('delete');
setDeleteError('');
setDeleteConfirm({ open: true, mode: 'batch' });
}}
>
......@@ -1169,9 +1164,9 @@ export default function IntegrityPartyPage() {
</Button>
</>
)}
/>
/>}
<DataTablePagination
{!loadFailed ? <DataTablePagination
page={page}
pageSize={pageSize}
pageSizeOptions={[20, 50, 100]}
......@@ -1183,19 +1178,23 @@ export default function IntegrityPartyPage() {
setPage(0);
reload(0, filters[activeKey], nextPageSize);
}}
/>
/> : null}
</section>
</>}
<XinyuanModal
error={dialogErrors.detail}
error={detailValidationMessage}
isOpen={detailOpen}
onOpenChange={handleDetailOpenChange}
size="4xl"
title={detailMode === 'create' ? `新建${activeConfig.tabLabel}记录` : detailMode === 'view' ? '预览台账记录' : '编辑台账记录'}
footer={
detailMode === 'view' ? (
<Button type="button" size="sm" variant="primary" onPress={() => setDetailMode('edit')}>
<Button type="button" size="sm" variant="primary" onPress={() => {
setDetailSuccess('');
setDetailError('');
setDetailMode('edit');
}}>
编辑
</Button>
) : (
......@@ -1234,11 +1233,10 @@ export default function IntegrityPartyPage() {
</XinyuanModal>
<XinyuanModal
error={dialogErrors.import}
isOpen={importOpen}
onOpenChange={(nextOpen) => {
setImportOpen(nextOpen);
if (!nextOpen) setDialogError('import');
if (!nextOpen) setImportError('');
}}
size="4xl"
title={`Excel 导入${activeConfig.tabLabel}`}
......@@ -1252,7 +1250,7 @@ export default function IntegrityPartyPage() {
size="sm"
variant="secondary"
onPress={() => {
setDialogError('import');
setImportError('');
setImportOpen(false);
}}
>
......@@ -1306,11 +1304,10 @@ export default function IntegrityPartyPage() {
</XinyuanModal>
<XinyuanModal
error={dialogErrors.batch}
isOpen={batchOpen}
onOpenChange={(nextOpen) => {
setBatchOpen(nextOpen);
if (!nextOpen) setDialogError('batch');
if (!nextOpen) setBatchError('');
}}
size="3xl"
title={`批量修改${activeConfig.tabLabel}`}
......@@ -1321,7 +1318,7 @@ export default function IntegrityPartyPage() {
size="sm"
variant="secondary"
onPress={() => {
setDialogError('batch');
setBatchError('');
setBatchOpen(false);
}}
>
......@@ -1351,11 +1348,10 @@ export default function IntegrityPartyPage() {
</XinyuanModal>
<XinyuanModal
error={dialogErrors.delete}
isOpen={deleteConfirm.open}
onOpenChange={(open) => {
setDeleteConfirm((current) => ({ ...current, open }));
if (!open) setDialogError('delete');
if (!open) setDeleteError('');
}}
size="md"
title={deleteConfirm.mode === 'batch' ? '确认批量删除' : '确认删除记录'}
......@@ -1366,7 +1362,7 @@ export default function IntegrityPartyPage() {
size="sm"
variant="secondary"
onPress={() => {
setDialogError('delete');
setDeleteError('');
setDeleteConfirm({ open: false, mode: 'single' });
}}
>
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { Alert, Avatar, Button, Card, Chip, SearchField, Spinner, Tabs, toast } from '@heroui/react';
import { Avatar, Button, Card, Chip, SearchField, Spinner, Tabs, toast } from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi';
import {
......@@ -75,7 +76,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0);
const [unread, setUnread] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
......@@ -92,7 +93,7 @@ export default function NotificationListPage() {
.catch((requestError) => { if (!cancelled) { setItems([]); setTotal(0); setError(requestError?.message || '通知列表加载失败,请稍后重试'); } })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [category, keyword, page, readStatus, reloadKey]);
}, [category, keyword, page, readStatus, reloadKey, setError]);
useEffect(() => {
let cancelled = false;
......@@ -103,8 +104,8 @@ export default function NotificationListPage() {
const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const run = useCallback(async (key, operation, message) => {
setBusyKey(key);
try { await operation(); toast.success(message, { timeout: 3000 }); refresh(); return true; }
catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); return false; }
try { await operation(); toast.success(message, { timeout: 4000 }); refresh(); return true; }
catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 8000 }); return false; }
finally { setBusyKey(''); }
}, [refresh]);
......@@ -115,7 +116,7 @@ export default function NotificationListPage() {
if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer');
else history.push(target.href);
refresh();
} catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 }); }
} catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 8000 }); }
finally { setBusyKey(''); }
}, [refresh, userInfo?._id]);
......@@ -135,7 +136,15 @@ export default function NotificationListPage() {
<Tabs.ListContainer><Tabs.List aria-label="通知状态">{READ_OPTIONS.map((option) => <Tabs.Tab id={option.key} key={option.key}>{option.label}<Tabs.Indicator /></Tabs.Tab>)}</Tabs.List></Tabs.ListContainer>
</Tabs>
</div>
{error ? <Alert status="danger"><Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content><Button size="sm" variant="ghost" onPress={refresh}>重试</Button></Alert> : null}
{error ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>通知加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content><Button variant="secondary" onPress={refresh}>重试</Button></EmptyState.Content>
</EmptyState>
) : null}
{loading && items.length === 0 ? <div className="flex min-h-64 items-center justify-center"><Spinner size="sm" /></div> : null}
{!loading && !error && items.length === 0 ? <EmptyState className="min-h-64" size="md"><EmptyState.Media><Bell size={22} /></EmptyState.Media><EmptyState.Header><EmptyState.Title>{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}</EmptyState.Title><EmptyState.Description>{keyword ? '没有找到匹配的通知,请调整搜索条件。' : readStatus === 'unread' ? '当前没有未读通知。' : '新的通知会显示在这里。'}</EmptyState.Description></EmptyState.Header></EmptyState> : null}
{items.length > 0 ? <div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>{items.map((item) => <NotificationItem category={category} currentUserId={userInfo?._id} isBusy={busyKey === item.name} key={item.name || item._id} notification={item} onAction={(notification, action) => run(notification.name, () => executeNotificationAction(notification, action), '待办已处理')} onMarkRead={(notification) => run(notification.name, () => markNotificationRead(notification.name), '已标记为已读')} onOpenTarget={openTarget} />)}</div> : null}
......
......@@ -6,16 +6,18 @@ import {
import {
XINYUAN_CHART_CATEGORY_PALETTE,
createResolvedXinyuanChartColorMap,
createResolvedXinyuanPieChartColorMap,
getXinyuanEChartsTheme,
resolveXinyuanChartColor,
XINYUAN_CHART_SEMANTIC,
} from '@/design-system/chartPalette';
import XinyuanEChart from '@/components/XinyuanEChart';
import { Alert, Card, Spinner } from '@heroui/react';
import { Button, Card, Spinner } from '@heroui/react';
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi';
import { useEffect, useState } from 'react';
import { loadIntegrityPartyDashboard } from '../integrityPartyService';
import { INTEGRITY_LEDGER_CONFIGS, loadIntegrityPartyDashboard } from '../integrityPartyService';
function formatCount(value) {
const numericValue = Number(value || 0);
......@@ -87,13 +89,75 @@ function LedgerDistributionChart({ groups }) {
name: item.label,
value: Number(item.count || 0),
}));
const total = data.reduce((sum, item) => sum + item.value, 0);
const populatedCount = data.filter((item) => item.value > 0).length;
const colorMap = createResolvedXinyuanPieChartColorMap(data.map((item) => item.name));
const formatPercent = (value) => `${Number(((value / total) * 100).toFixed(1))}%`;
if (data.length === 0) return <EmptyBlock title="暂无台账分类数据" />;
if (total === 0) return <EmptyBlock title="暂无台账分类数据" />;
return <XinyuanEChart ariaLabel="台账分类分布图" option={{
legend: { bottom: 0, data: data.map((item) => item.name) },
tooltip: { trigger: 'item', formatter: ({ name, value, percent }) => `${name}<br/>数量:${value} 条(${percent}%)` },
series: [{ type: 'pie', radius: ['52%', '72%'], center: ['50%', '44%'], avoidLabelOverlap: true, data: data.map((item) => ({ name: item.name, value: item.value })), label: { alignTo: 'labelLine', distanceToLabelLine: 6, formatter: ({ name, value }) => `{name|${name}}\n{value|${value} 条}`, lineHeight: 18, rich: { name: { fontSize: 13, fontWeight: 500, lineHeight: 18 }, value: { fontSize: 14, fontWeight: 600, lineHeight: 20 } } }, labelLine: { length: 20, length2: 22, lineStyle: { width: 1.5 } }, labelLayout: (params) => { const points = params?.labelLinePoints; if (!Array.isArray(points) || points.length < 3) return null; const endpoint = points[2]; const isRight = endpoint[0] >= points[0][0]; return { align: isRight ? 'left' : 'right', hideOverlap: true, moveOverlap: 'none', verticalAlign: 'middle', x: endpoint[0] + (isRight ? 8 : -8), y: endpoint[1] }; } }] }} height={320} />;
return (
<div className="@container min-w-0">
<div className="grid min-h-80 items-center gap-6 py-2 @min-[30rem]:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
<div className="relative mx-auto w-full max-w-64">
<XinyuanEChart
ariaLabel={`台账分类分布图,共 ${formatCount(total)} 条,分类数量和占比详见分类明细表`}
height={248}
option={{
animation: !window.matchMedia('(prefers-reduced-motion: reduce)').matches,
tooltip: {
trigger: 'item',
formatter: ({ name, value }) => `${name}<br/>数量:${formatCount(value)} 条(${formatPercent(value)})`,
},
series: [{
type: 'pie',
radius: ['66%', '86%'],
center: ['50%', '50%'],
padAngle: populatedCount > 1 ? 2 : 0,
stillShowZeroSum: false,
data,
label: { show: false },
labelLine: { show: false },
emphasis: { scaleSize: 4 },
}],
}}
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
<span className="text-sm text-muted">台账总数</span>
<span className="max-w-[60%] truncate text-4xl font-semibold tracking-tight text-foreground tabular-nums" title={formatCount(total)}>
{formatCount(total)}
</span>
<span className="text-xs text-muted">条记录</span>
</div>
</div>
<table className="w-full table-fixed text-sm">
<caption className="sr-only">台账分类数量及占比明细</caption>
<thead>
<tr className="border-b border-border text-xs text-muted">
<th className="pb-3 text-left font-normal" scope="col">分类</th>
<th className="w-16 pb-3 text-right font-normal" scope="col">数量</th>
<th className="w-16 pb-3 text-right font-normal" scope="col">占比</th>
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr className="h-11" key={item.name}>
<th className="py-2 pr-2 text-left font-normal" scope="row">
<span className="flex items-center gap-2.5 text-foreground">
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: colorMap.get(item.name) }} />
<span>{item.name}</span>
</span>
</th>
<td className="text-right font-medium text-foreground tabular-nums">{formatCount(item.value)}</td>
<td className="text-right text-muted tabular-nums">{formatPercent(item.value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function YearDistributionChart({ groups }) {
......@@ -219,21 +283,26 @@ function TalkOverview({ summary }) {
export default function ResourceStatPage() {
const [integrityDashboard, setIntegrityDashboard] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [error, setError] = useFeedbackState('error');
const [loadFailed, setLoadFailed] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setLoadFailed(false);
setError('');
try {
const integrityResult = await loadIntegrityPartyDashboard();
if (cancelled) return;
setIntegrityDashboard(integrityResult);
setLoadFailed(integrityResult.errors?.length === INTEGRITY_LEDGER_CONFIGS.length);
setError((integrityResult.errors || []).filter(Boolean).join(';'));
} catch (loadError) {
if (cancelled) return;
setIntegrityDashboard(null);
setLoadFailed(true);
setError(loadError?.message || '党风廉政驾驶舱加载失败');
} finally {
if (!cancelled) setLoading(false);
......@@ -243,7 +312,7 @@ export default function ResourceStatPage() {
return () => {
cancelled = true;
};
}, []);
}, [reloadKey, setError]);
const dashboard = integrityDashboard || {
totalCount: 0,
......@@ -261,21 +330,29 @@ export default function ResourceStatPage() {
return (
<div className="rc-page-stack">
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{loading ? (
<div className="flex min-h-48 items-center justify-center gap-2" role="status">
<Spinner size="sm" />
<span className="text-sm text-muted">正在加载驾驶舱...</span>
</div>
) : loadFailed ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>驾驶舱加载失败</EmptyState.Title>
<EmptyState.Description>详细错误见操作提示,请重新加载。</EmptyState.Description>
</EmptyState.Header>
<EmptyState.Content>
<Button variant="secondary" onPress={() => setReloadKey((key) => key + 1)}>重新加载</Button>
</EmptyState.Content>
</EmptyState>
) : (
<>
{error ? (
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-muted">部分台账加载失败,当前统计可能不完整。</span>
<Button size="sm" variant="secondary" onPress={() => setReloadKey((key) => key + 1)}>重新加载统计</Button>
</div>
) : null}
<section className="rc-page-section">
<div className="rc-kpi-grid" data-count="3">
<MetricCard icon={ClipboardDocumentListIcon} label="台账总数" tone="info" value={dashboard.totalCount} />
......
......@@ -9,9 +9,18 @@
1. 页面进入后并行查询培训、谈话、调研、专题会议、廉洁主题活动、节前廉洁提醒 6 类台账
2. 加载期间展示加载状态;全部请求结束后展示指标卡、台账分类分布、年度分布和本年度模块概览
3. 部分或全部请求失败时,在页面顶部展示错误信息;成功返回的数据仍按现有结果展示
3. 部分或全部请求失败时,通过根部 Toast 展示详细错误,默认 8 秒消失且可手动关闭;部分失败保留成功统计并标明统计可能不完整,提供“重新加载统计”;全部失败展示失败空状态与“重新加载”,不显示零值统计
4. 查询结果为空时,对应图表或概览区域展示空状态
### 台账分类分布展示
- 圆环中心显示当前统计的台账总数,右侧明细表按既有 6 类台账顺序展示分类、数量与占比;卡片内容宽度不足 30rem 时改为圆环在上、明细表在下
- 数量为 0 的分类保留在明细表中,显示 `0` 和 `0%`,圆环不显示外置标签及引导线,避免单分类有数据时零值标签堆叠
- 明细表色点与圆环共用共享饼图色板和完整分类集合,同一分类颜色保持一致;仅一个分类有数据时显示完整圆环,多个分类时保留扇区间隙
- 占比使用分类查询总数除以 6 类查询总数之和计算,最多显示一位小数;各分类数量直接展示查询总数,不受单类读取 500 条的限制
- 悬停扇区展示分类、数量和占比;明细表提供相同信息,支持无需悬停及屏幕阅读器读取。系统开启减少动态效果时关闭此圆环的入场与更新动画
- 6 类总数均为 0 时展示“暂无台账分类数据”,不绘制无业务含义的等分圆环;请求失败仍按上文失败与重试流程处理
## 3. 年度分布规则
- 年份取自各台账配置的年度字段:日期字段截取年份,数字年份字段直接转为文本
......@@ -34,6 +43,7 @@
## 5. 异常与限制
- API 错误信息统一展示在页面顶部,不隐藏已成功加载的统计结果
- API 错误信息使用高于弹窗和移动侧栏的 Toast;关闭提示不改变失败状态,不隐藏已成功加载的统计结果。悬停或键盘聚焦暂停计时,相同错误在重试失败时重新显示
- 重新加载仅并行读取当前 6 类台账,不执行写入操作;重试成功后移除失败状态或统计不完整标记
- 单类台账超过 500 条时,当前年度分布可能不包含第 501 条及之后的记录
- 年度字段为空时归入非年份分组,并排在有效年份之后
......@@ -7,6 +7,7 @@
@import "./design-system/theme.css";
@import "./design-system/tokens.css";
@import "./design-system/shell.css";
@import "./design-system/feedback.css";
/* Keep Tailwind's watcher scoped to authored source. Umi's generated .umi
* files and hot-update artifacts must not trigger a CSS rebuild loop. */
......
import { getSystemInfo } from '@/services/DataService';
import useFeedbackState from '@/hooks/useFeedbackState';
import { APP_NAME } from '@/config/ApplicationConfig';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
......@@ -19,7 +20,7 @@ export default function SiteWrapper() {
const [siteInfo, setSiteInfo] = useState(null);
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [loadError, setLoadError] = useFeedbackState('warning');
useEffect(() => {
let cancelled = false;
......@@ -55,7 +56,7 @@ export default function SiteWrapper() {
return () => {
cancelled = true;
};
}, []);
}, [setLoadError]);
if (loading) {
return (
......
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