Commit 07133902 authored by drigle's avatar drigle

fix: improve save feedback and toast dismissal

parent ec8c19b4
...@@ -42,6 +42,7 @@ Card、Surface、字段和浮层仍按 HeroUI 原有 Token 渲染。渐变仅限 ...@@ -42,6 +42,7 @@ Card、Surface、字段和浮层仍按 HeroUI 原有 Token 渲染。渐变仅限
## 业务组件 ## 业务组件
- 接口反馈统一使用根部 `AppFeedback` 的 HeroUI Toast,保持视口顶部和层级 300,高于 Modal 与移动侧栏。关闭按钮同步问题整改 `bfc5862`:直接组合 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`)及 `AppIcons.Close`,使用官方 `slot="close"` 接入关闭行为,位于正文右侧并随内容参与布局。前排按钮始终可见,后排按钮隐藏;移除原 Toast.CloseButton 的放大边框、绝对位置和正文右侧占位,Hover/焦点使用官方默认实现。成功提示默认 4 秒、错误/警告默认 8 秒,关闭只影响展示。
- 筛选:HeroUI Surface、SearchField、Autocomplete、DatePicker、Disclosure。 - 筛选:HeroUI Surface、SearchField、Autocomplete、DatePicker、Disclosure。
- 列表:HeroUI Pro DataGrid,原生选择/排序/固定列/横向滚动;空态使用 `renderEmptyState`。 - 列表:HeroUI Pro DataGrid,原生选择/排序/固定列/横向滚动;空态使用 `renderEmptyState`。
- 分页:HeroUI compound Pagination 与 HeroUI Autocomplete 页容量选择器组合;业务列表默认 20 条/页,支持 20/50/100 条切换,最多五个连续页码,有数据才显示。 - 分页:HeroUI compound Pagination 与 HeroUI Autocomplete 页容量选择器组合;业务列表默认 20 条/页,支持 20/50/100 条切换,最多五个连续页码,有数据才显示。
......
{ {
"name": "model_analysis", "name": "model_analysis",
"version": "1.0.2", "version": "1.0.5",
"build": "abc", "build": "abc",
"description": "模型分析", "description": "模型分析",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import { Toast } from '@heroui/react'; import { Button, Toast } from '@heroui/react';
import { Close } from '@/components/AppIcons';
export default function AppFeedback({ children }) { export default function AppFeedback({ children }) {
return ( return (
...@@ -12,7 +13,7 @@ export default function AppFeedback({ children }) { ...@@ -12,7 +13,7 @@ export default function AppFeedback({ children }) {
width="min(30rem, calc(100vw - 2rem))" width="min(30rem, calc(100vw - 2rem))"
> >
{({ toast }) => ( {({ toast }) => (
<Toast className="app-feedback-toast" toast={toast} variant={toast.content.variant}> <Toast className="app-feedback-toast gap-2" toast={toast} variant={toast.content.variant}>
<Toast.Indicator variant={toast.content.variant} /> <Toast.Indicator variant={toast.content.variant} />
<Toast.Content className="min-w-0"> <Toast.Content className="min-w-0">
<Toast.Title>{toast.content.title}</Toast.Title> <Toast.Title>{toast.content.title}</Toast.Title>
...@@ -23,7 +24,16 @@ export default function AppFeedback({ children }) { ...@@ -23,7 +24,16 @@ export default function AppFeedback({ children }) {
) : null} ) : null}
{toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null} {toast.content.actionProps ? <Toast.ActionButton {...toast.content.actionProps} /> : null}
</Toast.Content> </Toast.Content>
<Toast.CloseButton aria-label="关闭提示" className="app-feedback-close" /> <Button
aria-label="关闭提示"
className="app-feedback-close shrink-0 self-start"
isIconOnly
size="sm"
slot="close"
variant="ghost"
>
<Close size={16} />
</Button>
</Toast> </Toast>
)} )}
</Toast.Provider> </Toast.Provider>
......
...@@ -101,6 +101,7 @@ export default function ForwardApprovals() { ...@@ -101,6 +101,7 @@ export default function ForwardApprovals() {
const [editSaving, setEditSaving] = useState(false); const [editSaving, setEditSaving] = useState(false);
const [, setError] = useFeedbackState(); const [, setError] = useFeedbackState();
const [, setNotice] = useFeedbackState('success'); const [, setNotice] = useFeedbackState('success');
const [, setSaveSuccess] = useFeedbackState('success');
const [, setNotificationWarning] = useFeedbackState('warning'); const [, setNotificationWarning] = useFeedbackState('warning');
const [activeRecordId, setActiveRecordId] = useState(''); const [activeRecordId, setActiveRecordId] = useState('');
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
...@@ -259,6 +260,7 @@ export default function ForwardApprovals() { ...@@ -259,6 +260,7 @@ export default function ForwardApprovals() {
setIsEditDialogOpen(true); setIsEditDialogOpen(true);
setEditLoading(true); setEditLoading(true);
setError(''); setError('');
setSaveSuccess('');
try { try {
const result = await loadForwardEditableRecords(record); const result = await loadForwardEditableRecords(record);
...@@ -277,6 +279,7 @@ export default function ForwardApprovals() { ...@@ -277,6 +279,7 @@ export default function ForwardApprovals() {
} }
function handleChangeEditableRecord(recordId, fieldAlias, nextValue) { function handleChangeEditableRecord(recordId, fieldAlias, nextValue) {
setSaveSuccess('');
const aliasMap = { const aliasMap = {
truth_status: 'truthStatus', truth_status: 'truthStatus',
rectification_plan: 'rectificationPlan', rectification_plan: 'rectificationPlan',
...@@ -298,12 +301,15 @@ export default function ForwardApprovals() { ...@@ -298,12 +301,15 @@ export default function ForwardApprovals() {
} }
async function handleSaveEditableRecords() { async function handleSaveEditableRecords() {
if (editLoading || editSaving || editableRecords.length === 0) return;
if (!canRecipientEdit(editingRecord, currentUserId)) { if (!canRecipientEdit(editingRecord, currentUserId)) {
setError('当前用户不是可填写接收人,或该转发记录已完成填写。'); setError('当前用户不是可填写接收人,或该转发记录已完成填写。');
return; return;
} }
setEditSaving(true); setEditSaving(true);
setError('');
setSaveSuccess('');
try { try {
const saved = await saveForwardEditableRecords( const saved = await saveForwardEditableRecords(
editingRecord, editingRecord,
...@@ -311,7 +317,7 @@ export default function ForwardApprovals() { ...@@ -311,7 +317,7 @@ export default function ForwardApprovals() {
userInfo, userInfo,
); );
setEditableRecords(saved); setEditableRecords(saved);
setNotice('填写数据已保存。'); setSaveSuccess('填写数据已保存。');
setError(''); setError('');
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '填写数据保存失败'); setError(saveError?.message || '填写数据保存失败');
...@@ -321,6 +327,8 @@ export default function ForwardApprovals() { ...@@ -321,6 +327,8 @@ export default function ForwardApprovals() {
} }
async function handleCompleteEditing() { async function handleCompleteEditing() {
if (editLoading || editSaving) return;
setSaveSuccess('');
if (!canRecipientEdit(editingRecord, currentUserId)) { if (!canRecipientEdit(editingRecord, currentUserId)) {
setError('当前用户不是可填写接收人,或该转发记录已完成填写。'); setError('当前用户不是可填写接收人,或该转发记录已完成填写。');
return; return;
...@@ -614,12 +622,13 @@ export default function ForwardApprovals() { ...@@ -614,12 +622,13 @@ export default function ForwardApprovals() {
</XinyuanModal> </XinyuanModal>
<XinyuanModal <XinyuanModal
footer={<><Button variant="secondary" onPress={() => { setIsEditDialogOpen(false); setEditingRecord(null); }}>关闭</Button><Button isDisabled={editLoading || editSaving || editableRecords.length === 0 || !canEditingRecordEdit} isPending={editSaving} variant="secondary" onPress={handleSaveEditableRecords}>保存修改</Button><Button isDisabled={editLoading || editSaving || editableRecords.length === 0 || !canEditingRecordEdit} isPending={editSaving} variant="primary" onPress={handleCompleteEditing}>完成填写</Button></>} footer={<><Button isDisabled={editSaving} variant="secondary" onPress={() => { setIsEditDialogOpen(false); setEditingRecord(null); }}>关闭</Button><Button isDisabled={editLoading || editSaving || editableRecords.length === 0 || !canEditingRecordEdit} isPending={editSaving} variant="secondary" onPress={handleSaveEditableRecords}>保存修改</Button><Button isDisabled={editLoading || editSaving || editableRecords.length === 0 || !canEditingRecordEdit} isPending={editSaving} variant="primary" onPress={handleCompleteEditing}>完成填写</Button></>}
isDismissable={!editSaving} isDismissable={!editSaving}
isOpen={isEditDialogOpen} isOpen={isEditDialogOpen}
size="4xl" size="4xl"
title="填写下发数据" title="填写下发数据"
onOpenChange={(nextOpen) => { onOpenChange={(nextOpen) => {
if (editSaving) return;
setIsEditDialogOpen(nextOpen); setIsEditDialogOpen(nextOpen);
if (!nextOpen) setEditingRecord(null); if (!nextOpen) setEditingRecord(null);
}} }}
......
...@@ -149,12 +149,15 @@ export default function Models() { ...@@ -149,12 +149,15 @@ export default function Models() {
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
const [, setError] = useFeedbackState(); const [, setError] = useFeedbackState();
const [, setNotice] = useFeedbackState('success'); const [, setNotice] = useFeedbackState('success');
const [, setModelSaveSuccess] = useFeedbackState('success');
const [, setModelSaveWarning] = useFeedbackState('warning');
const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false); const [isGroupDialogOpen, setIsGroupDialogOpen] = useState(false);
const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false); const [isDeleteGroupDialogOpen, setIsDeleteGroupDialogOpen] = useState(false);
const [deleteTargetGroup, setDeleteTargetGroup] = useState(null); const [deleteTargetGroup, setDeleteTargetGroup] = useState(null);
const [groupForm, setGroupForm] = useState({ recordName: '', groupName: '', parentGroupId: '' }); const [groupForm, setGroupForm] = useState({ recordName: '', groupName: '', parentGroupId: '' });
const [isModelDialogOpen, setIsModelDialogOpen] = useState(false); const [isModelDialogOpen, setIsModelDialogOpen] = useState(false);
const [modelSaving, setModelSaving] = useState(false);
const [editingModel, setEditingModel] = useState(null); const [editingModel, setEditingModel] = useState(null);
const [modelForm, setModelForm] = useState({ description: '', groupId: '', tags: '' }); const [modelForm, setModelForm] = useState({ description: '', groupId: '', tags: '' });
...@@ -296,14 +299,21 @@ export default function Models() { ...@@ -296,14 +299,21 @@ export default function Models() {
} }
function openModelDialog(model) { function openModelDialog(model) {
setError('');
setModelSaveSuccess('');
setModelSaveWarning('');
setEditingModel(model); setEditingModel(model);
setModelForm({ description: model.description, groupId: model.groupId, tags: model.tags.join(', ') }); setModelForm({ description: model.description, groupId: model.groupId, tags: model.tags.join(', ') });
setIsModelDialogOpen(true); setIsModelDialogOpen(true);
} }
async function handleSaveModel() { async function handleSaveModel() {
if (!editingModel) return; if (!editingModel || modelSaving) return;
const group = groups.find((item) => item.id === modelForm.groupId); const group = groups.find((item) => item.id === modelForm.groupId);
setModelSaving(true);
setError('');
setModelSaveSuccess('');
setModelSaveWarning('');
try { try {
await saveModelDetails(editingModel, { await saveModelDetails(editingModel, {
description: modelForm.description, description: modelForm.description,
...@@ -311,12 +321,17 @@ export default function Models() { ...@@ -311,12 +321,17 @@ export default function Models() {
groupName: group?.groupName || '', groupName: group?.groupName || '',
tags: modelForm.tags.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean), tags: modelForm.tags.split(/[,,]/).map((tag) => tag.trim()).filter(Boolean),
}); });
await refresh();
setIsModelDialogOpen(false); setIsModelDialogOpen(false);
setNotice('模型归档信息已保存。'); setModelSaveSuccess('模型详情已保存。');
setError(''); try {
await refresh();
} catch (refreshError) {
setModelSaveWarning(`模型详情已保存,但列表刷新失败,请重新加载。${refreshError?.message ? ` ${refreshError.message}` : ''}`);
}
} catch (saveError) { } catch (saveError) {
setError(saveError?.message || '模型信息保存失败。'); setError(saveError?.message || '模型信息保存失败。');
} finally {
setModelSaving(false);
} }
} }
...@@ -577,11 +592,12 @@ export default function Models() { ...@@ -577,11 +592,12 @@ export default function Models() {
</AlertDialog.Root> </AlertDialog.Root>
<XinyuanModal <XinyuanModal
footer={<><Button variant="secondary" onPress={() => setIsModelDialogOpen(false)}>取消</Button><Button variant="primary" onPress={handleSaveModel}>保存</Button></>} footer={<><Button isDisabled={modelSaving} variant="secondary" onPress={() => setIsModelDialogOpen(false)}>取消</Button><Button isDisabled={modelSaving} isPending={modelSaving} variant="primary" onPress={handleSaveModel}>保存</Button></>}
isDismissable={!modelSaving}
isOpen={isModelDialogOpen} isOpen={isModelDialogOpen}
size="2xl" size="2xl"
title="编辑模型归档信息" title="编辑模型归档信息"
onOpenChange={setIsModelDialogOpen} onOpenChange={(nextOpen) => { if (!modelSaving) setIsModelDialogOpen(nextOpen); }}
> >
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"><TextField fullWidth isDisabled value={editingModel?.modelName || ''}><FieldLabel>模型名称</FieldLabel><Input fullWidth variant="secondary" /></TextField><TextField fullWidth isDisabled value={editingModel?.modelCode || ''}><FieldLabel>模型编码</FieldLabel><Input fullWidth variant="secondary" /></TextField></div> <div className="grid gap-4 sm:grid-cols-2"><TextField fullWidth isDisabled value={editingModel?.modelName || ''}><FieldLabel>模型名称</FieldLabel><Input fullWidth variant="secondary" /></TextField><TextField fullWidth isDisabled value={editingModel?.modelCode || ''}><FieldLabel>模型编码</FieldLabel><Input fullWidth variant="secondary" /></TextField></div>
......
...@@ -118,7 +118,7 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -118,7 +118,7 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
3. 含子分组或模型的分组不得删除。 3. 含子分组或模型的分组不得删除。
4. 模型只允许编辑说明、分组和标签;外部模型编码、名称、主键及数据 Subject 不可编辑。 4. 模型只允许编辑说明、分组和标签;外部模型编码、名称、主键及数据 Subject 不可编辑。
分组保存流程:从分组操作打开编辑弹窗,修改分组名称或父级后保存;名称不能为空,父级不能选择当前分组或其后代。创建时调用 `POST /record?preview=false`,携带 `subject: model_monitor_group`;编辑时调用 `PUT /record/:name`,更新 `title`、`metadata.group_name` 和 `metadata.parent_group_id` 等可写字段,顶层不传只读 `subject`。统一更新 service 同样处理模型、批次和审批请求,兼容已有内部或外部对象。保存成功后重新加载分组和模型列表并关闭弹窗,显示成功提示;保存失败时保留表单并显示接口错误,不回退本地存储。 分组保存流程:从分组操作打开编辑弹窗,修改分组名称或父级后保存;名称不能为空,父级不能选择当前分组或其后代。创建时调用 `POST /record?preview=false`,携带 `subject: model_monitor_group`;编辑时调用 `PUT /record/:name`,更新 `title`、`metadata.group_name` 和 `metadata.parent_group_id` 等可写字段,顶层不传只读 `subject`。统一更新 service 同样处理模型、批次和审批请求,兼容已有内部或外部对象。分组保存成功后重新加载分组和模型列表并关闭弹窗,显示成功提示;保存失败时保留表单并显示接口错误,不回退本地存储。模型详情保存流程及列表刷新异常见“操作提示与关闭”。
### 4.5 查看批次与数据 ### 4.5 查看批次与数据
...@@ -175,7 +175,10 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql` ...@@ -175,7 +175,10 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
- 登录、站点配置、模型加载、分组/模型保存、同步、隐藏/恢复、转发、填写和审批失败时,统一显示 HeroUI Toast 浮层;用户查询失败同时在人员选择器内显示加载错误,保留当前表单和选中记录。 - 登录、站点配置、模型加载、分组/模型保存、同步、隐藏/恢复、转发、填写和审批失败时,统一显示 HeroUI Toast 浮层;用户查询失败同时在人员选择器内显示加载错误,保留当前表单和选中记录。
- 提示挂载在应用根部,通过 Portal 呈现在视口顶部,层级高于业务 Modal、删除确认和移动侧栏;弹窗打开时也能看到并关闭提示,提示出现不抢走表单焦点。 - 提示挂载在应用根部,通过 Portal 呈现在视口顶部,层级高于业务 Modal、删除确认和移动侧栏;弹窗打开时也能看到并关闭提示,提示出现不抢走表单焦点。
- 错误和通知处理异常提示默认 8 秒后消失,成功提示默认 4 秒后消失;悬停或键盘聚焦时暂停计时。关闭按钮始终可见,移动端也可直接点击,无需先悬停。 - 错误和通知处理异常提示默认 8 秒后消失,成功提示默认 4 秒后消失;悬停或键盘聚焦时暂停计时。沿用问题整改 `bfc5862`,当前提示的关闭图标始终可见,位于正文右侧,使用 HeroUI `Button`(`ghost`、`sm`、`isIconOnly`)和 `AppIcons.Close`,点击或键盘激活通过官方 `slot="close"` 关闭;后排堆叠提示隐藏关闭按钮,避免误关闭。按钮随正文布局,不使用绝对定位、放大边框或正文右侧占位。
- “全部模型 → 编辑模型归档信息 → 保存”调用现有 `saveModelDetails`(`PUT /record/:name`)更新 `model_monitor_model` 的说明、分组与标签等字段。写入成功后关闭编辑弹窗并立即显示“模型详情已保存”,再刷新模型/分组列表;列表刷新失败时另外显示“已保存,但列表刷新失败”的 warning,不把已完成写入误报为保存失败。写入失败保留弹窗与草稿,仅显示错误;保存中显示 pending,禁用重复提交、取消及弹窗关闭。
- “转发审批 → 填写下发数据 → 保存修改”通过现有 `saveForwardEditableRecords` 创建或更新 `model_monitor_forward_item`;只有全部回填写入成功后才显示“填写数据已保存”,弹窗保持打开并展示保存结果。任一写入失败保留填写草稿,仅显示错误。加载中、保存中、无记录或当前用户无填写权限时不提交、不显示成功提示;保存中禁用关闭与重复操作,不改变完成填写及审批状态。
- 模型详情重新打开或再次保存时清理上次保存成功/刷新警告;回填弹窗重新打开、继续修改字段、再次保存或完成填写时清理上次保存成功提示。成功提示由页面级状态管理,关闭弹窗不会立即清除刚产生的成功提示,仍按默认计时或手动关闭。
- 再次提交发生同样错误时重新弹出提示,并替换同一来源的上一条提示。清空错误或离开对应页面时清理该来源的提示;关闭提示只影响展示,不把失败状态改为成功、不清空表单、不触发保存或再次同步。 - 再次提交发生同样错误时重新弹出提示,并替换同一来源的上一条提示。清空错误或离开对应页面时清理该来源的提示;关闭提示只影响展示,不把失败状态改为成功、不清空表单、不触发保存或再次同步。
- 模型总览、模型列表、批次、批次数据和转发审批加载失败时显示可重新加载的空状态,详细原因由浮层提示;重新加载只读取页面数据,不重新同步批次。通知中心的重试方式见对应流程文档。 - 模型总览、模型列表、批次、批次数据和转发审批加载失败时显示可重新加载的空状态,详细原因由浮层提示;重新加载只读取页面数据,不重新同步批次。通知中心的重试方式见对应流程文档。
- 单批次同步返回部分写入失败时显示异常提示,不显示完整成功;批次继续保留错误摘要和不可再次同步限制。 - 单批次同步返回部分写入失败时显示异常提示,不显示完整成功;批次继续保留错误摘要和不可再次同步限制。
......
...@@ -35,6 +35,6 @@ ...@@ -35,6 +35,6 @@
- 所有请求继续使用现有登录 Token、请求拦截器和退出处理,不新增通知专用鉴权。 - 所有请求继续使用现有登录 Token、请求拦截器和退出处理,不新增通知专用鉴权。
- 接口 401/403、网络失败或动态 action 配置缺失时不得伪造成功状态或未读数量。 - 接口 401/403、网络失败或动态 action 配置缺失时不得伪造成功状态或未读数量。
- 加载或操作错误使用应用统一 HeroUI Toast 浮层,覆盖在弹窗和侧栏上方;关闭按钮始终可见,加载错误默认 8 秒自动消失,操作错误默认 4 秒自动消失,悬停/聚焦暂停计时。列表加载失败时保留“重试”空状态,关闭提示不清除失败状态,点击重试后重新请求列表。 - 加载或操作错误使用应用统一 HeroUI Toast 浮层,覆盖在弹窗和侧栏上方;当前提示右侧使用始终可见的小号透明关闭按钮,通过 Toast 的 `close` slot 关闭,后排堆叠提示隐藏关闭按钮。错误/警告默认 8 秒、成功默认 4 秒自动消失,悬停/聚焦暂停计时。列表加载失败时保留“重试”空状态,关闭提示不清除失败状态,点击重试后重新请求列表。
- 通知中心不读取或修改模型监控 Subject、Record、审批字段及用户 `applications` 授权结构。 - 通知中心不读取或修改模型监控 Subject、Record、审批字段及用户 `applications` 授权结构。
- 本应用不在五项完整功能白名单中,不实现 `shortTitle`、Subject View 权限或审计材料中心文件工作流。 - 本应用不在五项完整功能白名单中,不实现 `shortTitle`、Subject View 权限或审计材料中心文件工作流。
...@@ -37,18 +37,10 @@ html { ...@@ -37,18 +37,10 @@ html {
top: max(1rem, env(safe-area-inset-top)); top: max(1rem, env(safe-area-inset-top));
} }
.app-feedback-toast.toast { /* Only the front toast is interactive; its ghost close button stays visible without hover. */
gap: 0.5rem; .app-feedback-toast:not([data-frontmost="true"]) .app-feedback-close {
padding-inline-end: 3.5rem; visibility: hidden;
} pointer-events: none;
.app-feedback-toast[data-frontmost="true"] .app-feedback-close {
top: 0.25rem;
right: 0.25rem;
width: 2.75rem;
height: 2.75rem;
opacity: 1;
pointer-events: auto;
} }
body { body {
......
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