Skip to content

feat(record-detail): 记录详情页头部增加手动刷新按钮(不 reload 浏览器即可刷新详情/相关/计数) #3460

Description

@baozhoutao

背景 / 需求

来自平台用户的优化建议(MES 派工单场景):车间多操作员并发作业时,A 操作员在详情页停留期间,B 操作员对同一单据做了开工/报工等操作,A 只能靠浏览器 F5 才能看到最新状态。希望在记录详情页头部提供一个刷新按钮,点击后不 reload 页面、原地刷新详情数据。

现状调研结论:管道已齐,只缺一个手动扳机

  • 长期治理:客户端失效总线取代 key 重挂载(refreshKey/actionRefreshKey)+ UI 状态分类规则 + URL 参数注册表(#2257 后续) #2269 的数据失效总线(packages/react/src/data-invalidation.ts)已实现 "refresh data, don't rebuild UI":notifyDataChanged({...}) → 所有挂载中的读者原地重拉,不 remount,Tab/滚动/行内编辑草稿全保留。
  • RecordDetailView 的主记录加载 effect 已把 recordInvalidationNonce 挂进依赖数组;RecordContextValue已预留 refresh?: () => void 字段(当前无生产者)。
  • 计数 store(related-count-store.ts)对 '*' 通配已支持(invalidateAll);useDataInvalidationdataChangeMatches'*' 也匹配。
  • 唯一缺口RelatedList 的旧事件监听(objectui:related-changed)只认自己的对象名,不认 '*' 通配——一行修复。

推荐方案(已探索验证,patch 见下)

  1. 触发RecordDetailView 提供 RecordContext.refresh = () => notifyDataChanged({ objectName: '*' })。用 '*' 而非本记录,是因为用户点刷新的动机就是"别人改了我看不见的数据"——本客户端不可能知道具体动了哪些对象;'*' 让主记录、相关子列表、页签计数徽标全部原地重拉。
  2. 渲染PageHeaderRenderer 检测 ctx.refresh 存在时,在头部操作区最右端渲染 ⟳ 图标按钮——作为页面 chrome 而非动作管线成员(业务按钮随对象/状态变化,刷新按钮位置必须恒定;也避开能力门控与 overflow 预算)。点击后图标至少转 650ms 作为反馈。
  3. i18n:复用现成 common.refresh(十种语言均已存在),零 i18n 改动。
  4. RelatedList 一行修复:旧事件监听放行 '*' 通配(与 dataChangeMatches 语义对齐)。

已验证(真实浏览器,stub 数据源模拟"他人写入")

  • type-check:components / app-shell / plugin-detail 全过;相关单测(page-header-actions、失效总线、RelatedList、防 remount ratchet 等 50 个)全绿。
  • 浏览器验证:点击 ⟳ 后 findOne 恰好 +1;页面未 reload(预埋 window 标记存活);状态条推进(00 待开始 → 01 进行中)、字段值更新、相关页签计数 1→2 同步刷新;当前 Tab 与滚动位置保留。
探索性实现 patch(基于 main @ ca0fa8f,3 个文件 +73/-4,可直接 git apply)
diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx
index bf15188..799f3f1 100644
--- a/packages/app-shell/src/views/RecordDetailView.tsx
+++ b/packages/app-shell/src/views/RecordDetailView.tsx
@@ -358,6 +358,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
   const notifyRecordChanged = useCallback(() => {
     if (objectName) notifyDataChanged({ objectName, recordId: pureRecordId || undefined });
   }, [objectName, pureRecordId]);
+  // Manual refresh (header ⟳ button, rendered by PageHeaderRenderer when the
+  // host provides `RecordContext.refresh`). Scope is deliberately `'*'`, not
+  // this record: the reason a user reaches for refresh is a write made by
+  // SOMEONE ELSE (another operator started the work order, a child row got
+  // reported) — a write this client never saw, so it cannot know which
+  // objects it touched. `'*'` treats everything mounted as stale: the main
+  // record, every related child list, and the tab-count badges all refetch
+  // in place over the #2269 bus — no remount, tab/scroll/draft state kept.
+  const handleManualRefresh = useCallback(() => {
+    notifyDataChanged({ objectName: '*' });
+  }, []);
 
   // Record-scoped presence ("who else is viewing this record"). The default
   // PresenceProvider source is a no-op, so this resolves to `[]` until a
@@ -2074,6 +2085,8 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
         data={pageRecord}
         objectSchema={objectDef}
         dataSource={dataSource}
+        loading={pageRecordStatus === 'loading'}
+        refresh={handleManualRefresh}
         embedded={embedded}
         headerSystemActions={synthSystemActions}
         isFavorite={isRecordFavorite}
diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx
index 52ea202..3e228ee 100644
--- a/packages/components/src/renderers/layout/containers.tsx
+++ b/packages/components/src/renderers/layout/containers.tsx
@@ -45,10 +45,14 @@ import {
   DropdownMenuTrigger,
   DropdownMenuContent,
   DropdownMenuItem,
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
 } from '../../ui';
 import { RecordTitleChip } from '../../custom/RecordTitleChip';
 import { useObjectLabel, useSafeFieldLabel, useObjectTranslation, useSafeTranslate, createSafeTranslation, pickLocalized } from '@object-ui/i18n';
-import { MoreHorizontal } from 'lucide-react';
+import { MoreHorizontal, RefreshCw } from 'lucide-react';
 
 /**
  * Copy for the `page:tabs` count badge (objectstack#5506).
@@ -914,6 +918,48 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
   // the SAME key `action:menu`'s overflow trigger already reads, so the two
   // `⋯` buttons a record page can show cannot read differently per locale.
   const tt = useSafeTranslate();
+  // Manual page refresh — rendered as page CHROME at the far end of the
+  // header, not as a header action: business/system actions come and go per
+  // object and record state, while refresh must sit in the same place on
+  // every record page (and stay out of the action pipeline's capability
+  // gating and overflow budget). The host opts in by providing
+  // `RecordContext.refresh`; the fetch itself is quick, so the icon spins
+  // for a floor of ~650ms so the click visibly did something.
+  const hostRefresh = (ctx as any)?.refresh as (() => void) | undefined;
+  const [manualRefreshing, setManualRefreshing] = React.useState(false);
+  const refreshSpinTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
+  React.useEffect(() => () => clearTimeout(refreshSpinTimer.current), []);
+  const handleManualRefresh = React.useCallback(() => {
+    if (!hostRefresh) return;
+    void hostRefresh();
+    setManualRefreshing(true);
+    clearTimeout(refreshSpinTimer.current);
+    refreshSpinTimer.current = setTimeout(() => setManualRefreshing(false), 650);
+  }, [hostRefresh]);
+  const refreshSpinning = manualRefreshing || !!ctx?.loading;
+  const refreshLabel = tt('common.refresh', 'Refresh');
+  // Styled as the ⋯ overflow trigger's twin (same outline pill, same
+  // icon size) so the header reads as ONE button family — a bare ghost
+  // icon next to a row of bordered pills looked detached.
+  const renderRefreshButton = () =>
+    hostRefresh ? (
+      <TooltipProvider>
+        <Tooltip>
+          <TooltipTrigger asChild>
+            <Button
+              variant="outline"
+              size="sm"
+              className="gap-1 px-2 shrink-0"
+              onClick={handleManualRefresh}
+              aria-label={refreshLabel}
+            >
+              <RefreshCw className={cn('h-4 w-4', refreshSpinning && 'animate-spin')} />
+            </Button>
+          </TooltipTrigger>
+          <TooltipContent>{refreshLabel}</TooltipContent>
+        </Tooltip>
+      </TooltipProvider>
+    ) : null;
   // Spec bridge may either inline `properties.*` onto the node or preserve
   // the raw bag (see record:quick_actions for the same pattern). Read from
   // both so a `{ properties: { title } }` schema is rendered correctly.
@@ -1406,7 +1452,14 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
             <p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
           )}
         </div>
-        {renderHeaderActions() ?? <div data-page-actions-slot className="shrink-0" />}
+        {hostRefresh ? (
+          <div className="flex items-center gap-2 shrink-0">
+            {renderHeaderActions() ?? <div data-page-actions-slot />}
+            {renderRefreshButton()}
+          </div>
+        ) : (
+          renderHeaderActions() ?? <div data-page-actions-slot className="shrink-0" />
+        )}
       </header>
     );
   }
diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx
index f41fe2b..7d38d31 100644
--- a/packages/plugin-detail/src/RelatedList.tsx
+++ b/packages/plugin-detail/src/RelatedList.tsx
@@ -508,12 +508,15 @@ export const RelatedList: React.FC<RelatedListProps> = ({
   // Refetch when a mutation elsewhere signals this related object changed —
   // e.g. a child row action executed through the host retargets `api` and
   // dispatches `objectui:related-changed`. Only meaningful on the auto-fetch
-  // path (parent-provided data is refreshed by the parent).
+  // path (parent-provided data is refreshed by the parent). `'*'` is the
+  // bus's "unknown scope — everything is stale" wildcard (undo of an unknown
+  // op, the record header's manual ⟳): it must match every list, mirroring
+  // `dataChangeMatches` in @object-ui/react.
   React.useEffect(() => {
     if (!api || dataProvided) return;
     const onChanged = (ev: Event) => {
       const detail = (ev as CustomEvent).detail || {};
-      if (detail.objectName && detail.objectName !== api) return;
+      if (detail.objectName && detail.objectName !== '*' && detail.objectName !== api) return;
       setRefreshNonce((n) => n + 1);
     };
     window.addEventListener('objectui:related-changed', onChanged as EventListener);

浏览器复现方式:可按 apps/console/record-header-preview.html#3391 的 harness 模式)新建一个 dev-only 预览入口——挂载真实 RecordDetailView + stub dataSource,window.__serverBump() 模拟他端写入后点击 ⟳ 验证原地刷新。探索时的 harness 未随 patch 附带,实现者可按此描述重建(约 200 行)。

待产品定夺的 UI 决策(本次未定,故只提 issue)

  1. 按钮样式(两版都已实现过):
    • A. ghost 裸图标 + 细分割线,与操作区弱隔离——低视觉权重,但评审反馈"像飘在外面的孤儿,丑";
    • B. 与「⋯」同款的 outline 胶囊(同高/同边框/同内边距),整排 [开工][催办][编辑][⋯][⟳] 读作一个按钮家族——patch 采用的是 B。
  2. 位置备选:操作区最右端(patch 方案,位置恒定、利肌肉记忆)vs 标题行收藏 ☆ 旁(记录标识簇,但标题行拥挤且语义不合)。
  3. 范围:抽屉/分屏详情走 DetailView(plugin-detail),本方案未覆盖,是否同步加 ⟳ 待定。
  4. 远期:总线就是现成收口,后续可接 WebSocket 推送或轻量轮询实现自动刷新(等于服务端代按此按钮),与本方案完全兼容。

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions