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);
背景 / 需求
来自平台用户的优化建议(MES 派工单场景):车间多操作员并发作业时,A 操作员在详情页停留期间,B 操作员对同一单据做了开工/报工等操作,A 只能靠浏览器 F5 才能看到最新状态。希望在记录详情页头部提供一个刷新按钮,点击后不 reload 页面、原地刷新详情数据。
现状调研结论:管道已齐,只缺一个手动扳机
packages/react/src/data-invalidation.ts)已实现 "refresh data, don't rebuild UI":notifyDataChanged({...})→ 所有挂载中的读者原地重拉,不 remount,Tab/滚动/行内编辑草稿全保留。RecordDetailView的主记录加载 effect 已把recordInvalidationNonce挂进依赖数组;RecordContextValue里已预留refresh?: () => void字段(当前无生产者)。related-count-store.ts)对'*'通配已支持(invalidateAll);useDataInvalidation的dataChangeMatches对'*'也匹配。RelatedList的旧事件监听(objectui:related-changed)只认自己的对象名,不认'*'通配——一行修复。推荐方案(已探索验证,patch 见下)
RecordDetailView提供RecordContext.refresh = () => notifyDataChanged({ objectName: '*' })。用'*'而非本记录,是因为用户点刷新的动机就是"别人改了我看不见的数据"——本客户端不可能知道具体动了哪些对象;'*'让主记录、相关子列表、页签计数徽标全部原地重拉。PageHeaderRenderer检测ctx.refresh存在时,在头部操作区最右端渲染 ⟳ 图标按钮——作为页面 chrome 而非动作管线成员(业务按钮随对象/状态变化,刷新按钮位置必须恒定;也避开能力门控与 overflow 预算)。点击后图标至少转 650ms 作为反馈。common.refresh(十种语言均已存在),零 i18n 改动。RelatedList一行修复:旧事件监听放行'*'通配(与dataChangeMatches语义对齐)。已验证(真实浏览器,stub 数据源模拟"他人写入")
findOne恰好 +1;页面未 reload(预埋 window 标记存活);状态条推进(00 待开始 → 01 进行中)、字段值更新、相关页签计数 1→2 同步刷新;当前 Tab 与滚动位置保留。探索性实现 patch(基于 main @ ca0fa8f,3 个文件 +73/-4,可直接 git apply)
浏览器复现方式:可按
apps/console/record-header-preview.html(#3391 的 harness 模式)新建一个 dev-only 预览入口——挂载真实RecordDetailView+ stub dataSource,window.__serverBump()模拟他端写入后点击 ⟳ 验证原地刷新。探索时的 harness 未随 patch 附带,实现者可按此描述重建(约 200 行)。待产品定夺的 UI 决策(本次未定,故只提 issue)
[开工][催办][编辑][⋯][⟳]读作一个按钮家族——patch 采用的是 B。DetailView(plugin-detail),本方案未覆盖,是否同步加 ⟳ 待定。