Commit b2ccff62 authored by drigle's avatar drigle

fix: unify operation feedback and refine dashboard distribution

parent 31252990
...@@ -50,9 +50,18 @@ ...@@ -50,9 +50,18 @@
- 预览态普通标量字段使用无边框静态值:标签为 `text-muted`,值为 `text-foreground`,空值显示“暂无内容”;不得用灰色 disabled Input/Autocomplete 冒充预览。编辑态才显示 HeroUI `secondary` 控件;权限禁用、锁定或只读字段在同一网格中继续显示静态只读值。 - 预览态普通标量字段使用无边框静态值:标签为 `text-muted`,值为 `text-foreground`,空值显示“暂无内容”;不得用灰色 disabled Input/Autocomplete 冒充预览。编辑态才显示 HeroUI `secondary` 控件;权限禁用、锁定或只读字段在同一网格中继续显示静态只读值。
- 预览与编辑共用两列字段网格和字段顺序;长文本、附件等内容跨两列。编辑态使用 Footer 的 HeroUI `secondary` 取消 + `primary` 保存,保存只提交一次,取消丢弃草稿,不改变原有校验、权限和请求时机。 - 预览与编辑共用两列字段网格和字段顺序;长文本、附件等内容跨两列。编辑态使用 Footer 的 HeroUI `secondary` 取消 + `primary` 保存,保存只提交一次,取消丢弃草稿,不改变原有校验、权限和请求时机。
- 预览态 Footer 使用 HeroUI `primary` 编辑按钮作为主操作;点击后在同一个 Modal 和编辑会话内切换为编辑态,保留当前记录标题、字段草稿和材料草稿,不关闭后重新打开;进入编辑态后显示 `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 内滚动。 - 应用切换严格继承 `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` 图标。页容量选择器按上一条规则关闭清除图标。 - Autocomplete 下拉箭头与 DatePicker 日历图标使用 16px Ant Design 图标;可选的选择清除图标与日期清除图标使用统一的 14px Ant Design `Close` 图标。页容量选择器按上一条规则关闭清除图标。
......
{ {
"name": "incorrupt_party_build", "name": "incorrupt_party_build",
"version": "1.0.3", "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 '@/global.less'; import '@/global.less';
import { defineApp } from 'umi'; import { defineApp } from 'umi';
import { createElement } from 'react';
import AppFeedback from '@/components/AppFeedback';
export async function getInitialState() { export async function getInitialState() {
return { return {
...@@ -8,4 +10,8 @@ export async function getInitialState() { ...@@ -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 { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react';
import { history } from 'umi'; import { history } from 'umi';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog'; import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
...@@ -47,7 +46,6 @@ export default function AppShell({ ...@@ -47,7 +46,6 @@ export default function AppShell({
userInfo={sidebarProps.userInfo} userInfo={sidebarProps.userInfo}
onOpenChange={setIsApplicationDialogOpen} onOpenChange={setIsApplicationDialogOpen}
/> />
<Toast.Provider maxVisibleToasts={3} placement="top" />
</Sidebar.Provider> </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 xyLogoRed from '@/assets/identity/xy-logo-red.png';
import useFeedbackState from '@/hooks/useFeedbackState';
import xyLogoWhite from '@/assets/identity/xy-logo-white.png'; import xyLogoWhite from '@/assets/identity/xy-logo-white.png';
import { APP_BASE_PATH, APP_NAME } from '@/config/ApplicationConfig'; import { APP_BASE_PATH, APP_NAME } from '@/config/ApplicationConfig';
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 { import {
Alert,
Button, Button,
Card, Card,
Input, Input,
...@@ -55,7 +55,7 @@ export default function LoginPage() { ...@@ -55,7 +55,7 @@ export default function LoginPage() {
const [captchaData, setCaptchaData] = useState(null); const [captchaData, setCaptchaData] = useState(null);
const [captchaLoading, setCaptchaLoading] = useState(false); const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [, setError] = useFeedbackState('error');
const redirect = normalizeRedirect(searchParams.get('redirect')); const redirect = normalizeRedirect(searchParams.get('redirect'));
...@@ -209,14 +209,6 @@ export default function LoginPage() { ...@@ -209,14 +209,6 @@ export default function LoginPage() {
</div> </div>
</div> </div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<Button <Button
fullWidth fullWidth
className="login-submit-button" className="login-submit-button"
......
...@@ -5,12 +5,12 @@ import { ...@@ -5,12 +5,12 @@ import {
} from '@/config/ApplicationConfig'; } from '@/config/ApplicationConfig';
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 useFeedbackState from '@/hooks/useFeedbackState';
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,
...@@ -312,7 +312,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -312,7 +312,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;
...@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -352,6 +352,7 @@ export default function ApplicationSwitcherDialog({
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setApplicationSearch(''); setApplicationSearch('');
setErrorMessage('');
return; return;
} }
...@@ -392,7 +393,7 @@ export default function ApplicationSwitcherDialog({ ...@@ -392,7 +393,7 @@ export default function ApplicationSwitcherDialog({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [fallbackApplications, isOpen]); }, [fallbackApplications, isOpen, setErrorMessage]);
return ( return (
<Modal.Root> <Modal.Root>
...@@ -443,13 +444,6 @@ export default function ApplicationSwitcherDialog({ ...@@ -443,13 +444,6 @@ export default function ApplicationSwitcherDialog({
offset={12} offset={12}
size={24} size={24}
> >
{status === 'error' && errorMessage ? (
<Alert className="mb-4" status="warning">
<Alert.Indicator><ExclamationTriangleIcon size={20} /></Alert.Indicator>
<Alert.Content><Alert.Description>{errorMessage}</Alert.Description></Alert.Content>
</Alert>
) : null}
{status === 'loading' && applications.length === 0 ? ( {status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton /> <LoadingSkeleton />
) : filteredApplicationSections.length > 0 ? ( ) : filteredApplicationSections.length > 0 ? (
......
...@@ -130,6 +130,18 @@ ...@@ -130,6 +130,18 @@
5. 前端逐条调用 `PUT /record/:name` 5. 前端逐条调用 `PUT /record/:name`
6. 附件、长文本主要内容等字段一期不参与批量修改 6. 附件、长文本主要内容等字段一期不参与批量修改
### 4.9 操作提示与失败重试
- 功能入口:登录、站点配置、应用切换、6 类台账的查询/保存/删除/Excel 导入导出/批量修改、详情材料搜索与上传、驾驶舱和通知中心。
- 接口成功、失败及降级警告统一显示根部 HeroUI Toast,Portal 在视口顶部展示,层级高于详情弹窗、删除确认框和移动侧栏;页面滚动或弹窗打开时也可看到并关闭。
- 成功提示默认 4 秒、错误和警告默认 8 秒后消失;悬停或键盘聚焦暂停计时。当前提示右侧的关闭图标始终可见,使用 HeroUI 小号透明图标按钮,通过 Toast 的 `close` slot 支持点击或键盘关闭;后排堆叠提示隐藏关闭按钮以避免误关闭。同一来源的新提示替换旧提示,相同错误再次发生时重新弹出;清空反馈或离开对应页面时清理来源提示。
- 关闭提示仅影响展示,不改变请求结果、字段校验、表单输入、附件草稿、选中记录或分页筛选,不重新执行保存/删除/导入/批量修改/通知操作。必填校验摘要和字段错误仍保留在表单内,并定位第一个错误字段;批量修改和删除确认说明保留。
- 台账与通知查询失败时显示失败空状态及“重新加载/重试”,详细原因由 Toast 展示,关闭提示后不会误显示“暂无数据”。台账重试沿用当前分类、页码、页容量及页面筛选;通知重试沿用分类、已读状态、关键词和页码。重试仅重新读取资源,不重放写入操作。
- 驾驶舱全部查询失败时显示失败空状态和重试;部分失败时保留成功统计,标明统计可能不完整并提供重新加载入口。
- 材料查询失败时保留选择器内的加载错误与“重新加载材料”;上传错误单独提示,不影响材料搜索状态。多附件上传部分失败时保留已成功上传的附件草稿,提示已上传数量和未完成原因,保存详情时仍按原流程创建 `am_material`。
- 保存失败保留详情弹窗和草稿;Excel 导入、批量修改及批量删除部分失败时使用 warning 展示成功/失败条数。站点配置与应用列表降级使用 warning 并沿用原有回退信息。
- 台账详情点击“保存”后,只有待建档材料和记录创建/更新全部成功才显示独立的成功提示:编辑显示“详情已保存”,新建显示记录创建结果。沿用保存成功后关闭详情弹窗并刷新列表的交互;提示来源保留在台账页面,不随弹窗关闭或列表重载被清除,默认 4 秒消失且可手动关闭。重新开启新建、预览、编辑或再次保存时清理上一条成功提示;校验未通过、正在保存或写入失败时不显示成功提示,失败保留草稿和错误反馈。
## 5. 字段与数据规则 ## 5. 字段与数据规则
- 日期字段页面和 Excel 统一展示为 `YYYY-MM-DD` - 日期字段页面和 Excel 统一展示为 `YYYY-MM-DD`
...@@ -146,8 +158,8 @@ ...@@ -146,8 +158,8 @@
## 6. 异常处理与限制 ## 6. 异常处理与限制
- 保存失败时保留当前表单输入,弹窗保持打开,并在弹窗内提示用户;必填校验同时标记对应字段并定位到第一个错误字段 - 保存失败时保留当前表单输入,弹窗保持打开,通过高于弹窗的 Toast 提示用户;必填校验仍在弹窗内标记对应字段并定位到第一个错误字段
- Excel 导入、批量修改和删除确认在弹窗内失败时,错误信息在当前弹窗内展示,不要求用户先关闭弹窗 - Excel 导入、批量修改和删除确认在弹窗内失败时,通过 Toast 展示可关闭的错误提示,不要求用户先关闭弹窗
- Excel 解析失败时不发起创建请求 - Excel 解析失败时不发起创建请求
- Excel 批量导入单条失败不影响其它条目继续导入 - Excel 批量导入单条失败不影响其它条目继续导入
- 批量删除和单条删除均需要二次确认 - 批量删除和单条删除均需要二次确认
......
import { ArrowPathIcon, Delete } from '@/components/AppIcons'; import { ArrowPathIcon, Delete } from '@/components/AppIcons';
import FileUploadField from '@/components/FileUploadField'; import FileUploadField from '@/components/FileUploadField';
import XinyuanAutocomplete from '@/components/XinyuanAutocomplete'; import XinyuanAutocomplete from '@/components/XinyuanAutocomplete';
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 { useEffect, useMemo, useState } from 'react';
import { queryAuditMaterials, uploadIntegrityPartyFile } from '../integrityPartyMaterialService'; import { queryAuditMaterials, uploadIntegrityPartyFile } from '../integrityPartyMaterialService';
...@@ -16,19 +17,20 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath, ...@@ -16,19 +17,20 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
const [materials, setMaterials] = useState([]); const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploading, setUploading] = 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 [retry, setRetry] = useState(0);
const [localKeys, setLocalKeys] = useState(() => new Set()); const [localKeys, setLocalKeys] = useState(() => new Set());
const files = useMemo(() => (multiple ? toFiles(value) : toFiles(value).slice(0, 1)), [multiple, value]); 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(() => { const timer = window.setTimeout(() => setDebouncedKeyword(keyword), 300); return () => window.clearTimeout(timer); }, [keyword]);
useEffect(() => { useEffect(() => {
if (tab !== 'material-center') return undefined; if (tab !== 'material-center') return undefined;
let cancelled = false; setLoading(true); setError(''); let cancelled = false; setLoading(true); setMaterialsError('');
queryAuditMaterials(debouncedKeyword).then((items) => { if (!cancelled) setMaterials(items); }).catch((requestError) => { if (!cancelled) { setMaterials([]); setError(requestError?.message || '审计材料加载失败,请稍后重试。'); } }).finally(() => { if (!cancelled) setLoading(false); }); queryAuditMaterials(debouncedKeyword).then((items) => { if (!cancelled) setMaterials(items); }).catch((requestError) => { if (!cancelled) { setMaterials([]); setMaterialsError(requestError?.message || '审计材料加载失败,请稍后重试。'); } }).finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [debouncedKeyword, retry, tab]); }, [debouncedKeyword, retry, tab, setMaterialsError]);
function selectMaterial(key) { function selectMaterial(key) {
const material = materials.find((item) => String(item.id) === String(key || '')); const material = materials.find((item) => String(item.id) === String(key || ''));
...@@ -41,16 +43,22 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath, ...@@ -41,16 +43,22 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
async function uploadLocal(selected) { async function uploadLocal(selected) {
const inputFiles = multiple ? toFiles(selected) : toFiles(selected).slice(0, 1); const inputFiles = multiple ? toFiles(selected) : toFiles(selected).slice(0, 1);
if (inputFiles.length === 0) return; if (inputFiles.length === 0) return;
setUploading(true); setError(''); setUploading(true); setUploadError('');
const uploaded = []; const uploaded = [];
try { try {
for (const file of inputFiles) uploaded.push(await uploadIntegrityPartyFile(file)); for (const file of inputFiles) uploaded.push(await uploadIntegrityPartyFile(file));
const pending = uploaded.map((asset) => ({ clientId: fileKey(asset), fileAsset: asset, materialRecordId: null })); } catch (uploadError) {
setLocalKeys((current) => new Set(multiple ? [...current, ...pending.map((item) => item.clientId)] : pending.map((item) => item.clientId))); const message = uploadError?.message || '附件上传失败,请稍后重试。';
onPendingChange(fieldPath, { type: multiple ? 'add' : 'replace', materials: pending }); setUploadError(uploaded.length > 0 ? `已上传 ${uploaded.length} 个附件,其余附件未完成上传:${message}` : message);
onChange(multiple ? uniqueFiles([...files, ...uploaded]) : uploaded[uploaded.length - 1]); } finally {
} catch (uploadError) { setError(uploadError?.message || '附件上传失败,请稍后重试。'); } if (uploaded.length > 0) {
finally { setUploading(false); } 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]);
}
setUploading(false);
}
} }
function removeFile(file, index) { function removeFile(file, index) {
...@@ -87,6 +95,7 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath, ...@@ -87,6 +95,7 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
ariaLabel="选择审计材料" ariaLabel="选择审计材料"
className="w-full min-w-0" className="w-full min-w-0"
emptyText="没有找到可选择的审计材料" emptyText="没有找到可选择的审计材料"
errorText={materialsError}
inputValue={keyword} inputValue={keyword}
isDisabled={disabled || uploading} isDisabled={disabled || uploading}
isLoading={loading} isLoading={loading}
...@@ -106,16 +115,11 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath, ...@@ -106,16 +115,11 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
</span> </span>
)} )}
/> />
{error ? ( {materialsError ? (
<Alert className="mt-3" status="danger"> <Button className="mt-3" size="sm" variant="secondary" onPress={() => setRetry((value) => value + 1)}>
<Alert.Content> <ArrowPathIcon size={14} />
<Alert.Description>{error}</Alert.Description> 重新加载材料
</Alert.Content> </Button>
<Button size="sm" variant="danger-soft" onPress={() => setRetry((value) => value + 1)}>
<ArrowPathIcon size={14} />
重试
</Button>
</Alert>
) : null} ) : null}
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel className="mt-3 w-full min-w-0 p-0" id="local-upload"> <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, ...@@ -126,13 +130,6 @@ export default function IntegrityPartyMaterialField({ editSessionId, fieldPath,
triggerLabel={uploading ? '上传中...' : '选择本地文件'} triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={uploadLocal} onFilesChange={uploadLocal}
/> />
{error ? (
<Alert className="mt-3" status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
</div>; </div>;
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons'; import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable'; import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import useFeedbackState from '@/hooks/useFeedbackState';
import { EmptyState } from '@heroui-pro/react/empty-state'; 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 { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi'; import { history } from 'umi';
import { import {
...@@ -75,7 +76,7 @@ export default function NotificationListPage() { ...@@ -75,7 +76,7 @@ export default function NotificationListPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [unread, setUnread] = useState(0); const [unread, setUnread] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useFeedbackState('error');
const [busyKey, setBusyKey] = useState(''); const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
...@@ -92,7 +93,7 @@ export default function NotificationListPage() { ...@@ -92,7 +93,7 @@ export default function NotificationListPage() {
.catch((requestError) => { if (!cancelled) { setItems([]); setTotal(0); setError(requestError?.message || '通知列表加载失败,请稍后重试'); } }) .catch((requestError) => { if (!cancelled) { setItems([]); setTotal(0); setError(requestError?.message || '通知列表加载失败,请稍后重试'); } })
.finally(() => { if (!cancelled) setLoading(false); }); .finally(() => { 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;
...@@ -103,8 +104,8 @@ export default function NotificationListPage() { ...@@ -103,8 +104,8 @@ export default function NotificationListPage() {
const refresh = useCallback(() => setReloadKey((value) => value + 1), []); const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const run = useCallback(async (key, operation, message) => { const run = useCallback(async (key, operation, message) => {
setBusyKey(key); setBusyKey(key);
try { await operation(); toast.success(message, { timeout: 3000 }); refresh(); return true; } try { await operation(); toast.success(message, { timeout: 4000 }); refresh(); return true; }
catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 4000 }); return false; } catch (operationError) { toast.danger(operationError?.message || '操作失败,请稍后重试', { timeout: 8000 }); return false; }
finally { setBusyKey(''); } finally { setBusyKey(''); }
}, [refresh]); }, [refresh]);
...@@ -115,7 +116,7 @@ export default function NotificationListPage() { ...@@ -115,7 +116,7 @@ export default function NotificationListPage() {
if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer'); if (target.external) window.open(target.href, '_blank', 'noopener,noreferrer');
else history.push(target.href); else history.push(target.href);
refresh(); refresh();
} catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 4000 }); } } catch (operationError) { toast.danger(operationError?.message || '无法打开通知关联流程', { timeout: 8000 }); }
finally { setBusyKey(''); } finally { setBusyKey(''); }
}, [refresh, userInfo?._id]); }, [refresh, userInfo?._id]);
...@@ -135,7 +136,15 @@ export default function NotificationListPage() { ...@@ -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.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> </Tabs>
</div> </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 && 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} {!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} {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 { ...@@ -6,16 +6,18 @@ import {
import { import {
XINYUAN_CHART_CATEGORY_PALETTE, XINYUAN_CHART_CATEGORY_PALETTE,
createResolvedXinyuanChartColorMap, createResolvedXinyuanChartColorMap,
createResolvedXinyuanPieChartColorMap,
getXinyuanEChartsTheme, getXinyuanEChartsTheme,
resolveXinyuanChartColor, resolveXinyuanChartColor,
XINYUAN_CHART_SEMANTIC, XINYUAN_CHART_SEMANTIC,
} from '@/design-system/chartPalette'; } from '@/design-system/chartPalette';
import XinyuanEChart from '@/components/XinyuanEChart'; 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 { EmptyState } from '@heroui-pro/react/empty-state';
import { KPI } from '@heroui-pro/react/kpi'; import { KPI } from '@heroui-pro/react/kpi';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { loadIntegrityPartyDashboard } from '../integrityPartyService'; import { INTEGRITY_LEDGER_CONFIGS, loadIntegrityPartyDashboard } from '../integrityPartyService';
function formatCount(value) { function formatCount(value) {
const numericValue = Number(value || 0); const numericValue = Number(value || 0);
...@@ -87,13 +89,75 @@ function LedgerDistributionChart({ groups }) { ...@@ -87,13 +89,75 @@ function LedgerDistributionChart({ groups }) {
name: item.label, name: item.label,
value: Number(item.count || 0), 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={{ return (
legend: { bottom: 0, data: data.map((item) => item.name) }, <div className="@container min-w-0">
tooltip: { trigger: 'item', formatter: ({ name, value, percent }) => `${name}<br/>数量:${value} 条(${percent}%)` }, <div className="grid min-h-80 items-center gap-6 py-2 @min-[30rem]:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)]">
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} />; <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 }) { function YearDistributionChart({ groups }) {
...@@ -219,21 +283,26 @@ function TalkOverview({ summary }) { ...@@ -219,21 +283,26 @@ function TalkOverview({ summary }) {
export default function ResourceStatPage() { export default function ResourceStatPage() {
const [integrityDashboard, setIntegrityDashboard] = useState(null); const [integrityDashboard, setIntegrityDashboard] = useState(null);
const [loading, setLoading] = useState(true); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setLoadFailed(false);
setError(''); setError('');
try { try {
const integrityResult = await loadIntegrityPartyDashboard(); const integrityResult = await loadIntegrityPartyDashboard();
if (cancelled) return; if (cancelled) return;
setIntegrityDashboard(integrityResult); setIntegrityDashboard(integrityResult);
setLoadFailed(integrityResult.errors?.length === INTEGRITY_LEDGER_CONFIGS.length);
setError((integrityResult.errors || []).filter(Boolean).join(';')); setError((integrityResult.errors || []).filter(Boolean).join(';'));
} catch (loadError) { } catch (loadError) {
if (cancelled) return; if (cancelled) return;
setIntegrityDashboard(null); setIntegrityDashboard(null);
setLoadFailed(true);
setError(loadError?.message || '党风廉政驾驶舱加载失败'); setError(loadError?.message || '党风廉政驾驶舱加载失败');
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
...@@ -243,7 +312,7 @@ export default function ResourceStatPage() { ...@@ -243,7 +312,7 @@ export default function ResourceStatPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, []); }, [reloadKey, setError]);
const dashboard = integrityDashboard || { const dashboard = integrityDashboard || {
totalCount: 0, totalCount: 0,
...@@ -261,21 +330,29 @@ export default function ResourceStatPage() { ...@@ -261,21 +330,29 @@ export default function ResourceStatPage() {
return ( return (
<div className="rc-page-stack"> <div className="rc-page-stack">
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{loading ? ( {loading ? (
<div className="flex min-h-48 items-center justify-center gap-2" role="status"> <div className="flex min-h-48 items-center justify-center gap-2" role="status">
<Spinner size="sm" /> <Spinner size="sm" />
<span className="text-sm text-muted">正在加载驾驶舱...</span> <span className="text-sm text-muted">正在加载驾驶舱...</span>
</div> </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"> <section className="rc-page-section">
<div className="rc-kpi-grid" data-count="3"> <div className="rc-kpi-grid" data-count="3">
<MetricCard icon={ClipboardDocumentListIcon} label="台账总数" tone="info" value={dashboard.totalCount} /> <MetricCard icon={ClipboardDocumentListIcon} label="台账总数" tone="info" value={dashboard.totalCount} />
......
...@@ -9,9 +9,18 @@ ...@@ -9,9 +9,18 @@
1. 页面进入后并行查询培训、谈话、调研、专题会议、廉洁主题活动、节前廉洁提醒 6 类台账 1. 页面进入后并行查询培训、谈话、调研、专题会议、廉洁主题活动、节前廉洁提醒 6 类台账
2. 加载期间展示加载状态;全部请求结束后展示指标卡、台账分类分布、年度分布和本年度模块概览 2. 加载期间展示加载状态;全部请求结束后展示指标卡、台账分类分布、年度分布和本年度模块概览
3. 部分或全部请求失败时,在页面顶部展示错误信息;成功返回的数据仍按现有结果展示 3. 部分或全部请求失败时,通过根部 Toast 展示详细错误,默认 8 秒消失且可手动关闭;部分失败保留成功统计并标明统计可能不完整,提供“重新加载统计”;全部失败展示失败空状态与“重新加载”,不显示零值统计
4. 查询结果为空时,对应图表或概览区域展示空状态 4. 查询结果为空时,对应图表或概览区域展示空状态
### 台账分类分布展示
- 圆环中心显示当前统计的台账总数,右侧明细表按既有 6 类台账顺序展示分类、数量与占比;卡片内容宽度不足 30rem 时改为圆环在上、明细表在下
- 数量为 0 的分类保留在明细表中,显示 `0` 和 `0%`,圆环不显示外置标签及引导线,避免单分类有数据时零值标签堆叠
- 明细表色点与圆环共用共享饼图色板和完整分类集合,同一分类颜色保持一致;仅一个分类有数据时显示完整圆环,多个分类时保留扇区间隙
- 占比使用分类查询总数除以 6 类查询总数之和计算,最多显示一位小数;各分类数量直接展示查询总数,不受单类读取 500 条的限制
- 悬停扇区展示分类、数量和占比;明细表提供相同信息,支持无需悬停及屏幕阅读器读取。系统开启减少动态效果时关闭此圆环的入场与更新动画
- 6 类总数均为 0 时展示“暂无台账分类数据”,不绘制无业务含义的等分圆环;请求失败仍按上文失败与重试流程处理
## 3. 年度分布规则 ## 3. 年度分布规则
- 年份取自各台账配置的年度字段:日期字段截取年份,数字年份字段直接转为文本 - 年份取自各台账配置的年度字段:日期字段截取年份,数字年份字段直接转为文本
...@@ -34,6 +43,7 @@ ...@@ -34,6 +43,7 @@
## 5. 异常与限制 ## 5. 异常与限制
- API 错误信息统一展示在页面顶部,不隐藏已成功加载的统计结果 - API 错误信息使用高于弹窗和移动侧栏的 Toast;关闭提示不改变失败状态,不隐藏已成功加载的统计结果。悬停或键盘聚焦暂停计时,相同错误在重试失败时重新显示
- 重新加载仅并行读取当前 6 类台账,不执行写入操作;重试成功后移除失败状态或统计不完整标记
- 单类台账超过 500 条时,当前年度分布可能不包含第 501 条及之后的记录 - 单类台账超过 500 条时,当前年度分布可能不包含第 501 条及之后的记录
- 年度字段为空时归入非年份分组,并排在有效年份之后 - 年度字段为空时归入非年份分组,并排在有效年份之后
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
@import "./design-system/theme.css"; @import "./design-system/theme.css";
@import "./design-system/tokens.css"; @import "./design-system/tokens.css";
@import "./design-system/shell.css"; @import "./design-system/shell.css";
@import "./design-system/feedback.css";
/* Keep Tailwind's watcher scoped to authored source. Umi's generated .umi /* Keep Tailwind's watcher scoped to authored source. Umi's generated .umi
* files and hot-update artifacts must not trigger a CSS rebuild loop. */ * files and hot-update artifacts must not trigger a CSS rebuild loop. */
......
import { getSystemInfo } from '@/services/DataService'; import { getSystemInfo } from '@/services/DataService';
import useFeedbackState from '@/hooks/useFeedbackState';
import { APP_NAME } from '@/config/ApplicationConfig'; import { APP_NAME } from '@/config/ApplicationConfig';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
...@@ -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('warning');
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 (
......
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