增强UI编辑器状态高亮动效

概览重复定位时重新触发状态提示动画

为待审与阻塞状态提供差异化呼吸高亮

补充状态高亮令牌的回归测试
This commit is contained in:
2026-08-19 13:14:44 +08:00
parent f1ebd47ff7
commit 2cf3183e78
4 changed files with 138 additions and 10 deletions
+96
View File
@@ -23,6 +23,102 @@ textarea {
font: inherit;
}
@keyframes ui-editor-status-attention-arrive {
0%,
100% {
transform: scale(1);
}
16%,
50% {
transform: scale(1.012);
}
32%,
68% {
transform: scale(0.996);
}
}
@keyframes ui-editor-status-attention-ring {
0%,
100% {
opacity: 0;
transform: scale(0.96);
}
16%,
48% {
opacity: 0.78;
transform: scale(1.01);
}
32%,
68% {
opacity: 0.2;
transform: scale(1.025);
}
}
@keyframes ui-editor-status-attention-breathe {
0%,
100% {
opacity: 0.32;
transform: scale(0.995);
}
50% {
opacity: 0.7;
transform: scale(1.018);
}
}
.ui-editor-status-attention {
position: relative;
z-index: 0;
isolation: isolate;
border: 2px solid rgb(var(--ui-editor-status-attention-rgb) / 88%);
background: rgb(var(--ui-editor-status-attention-rgb) / 8%);
box-shadow:
0 0 0 4px rgb(var(--ui-editor-status-attention-rgb) / 16%),
0 8px 20px rgb(var(--ui-editor-status-attention-rgb) / 12%);
animation: ui-editor-status-attention-arrive 820ms
cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ui-editor-status-attention::after {
position: absolute;
z-index: -1;
inset: -6px;
border: 2px solid rgb(var(--ui-editor-status-attention-rgb) / 85%);
border-radius: 12px;
content: '';
pointer-events: none;
animation:
ui-editor-status-attention-ring 820ms ease-out,
ui-editor-status-attention-breathe 1.8s ease-in-out 820ms infinite;
}
.ui-editor-status-attention--review {
--ui-editor-status-attention-rgb: 245 158 11;
}
.ui-editor-status-attention--blocked {
--ui-editor-status-attention-rgb: 239 68 68;
}
@media (prefers-reduced-motion: reduce) {
.ui-editor-status-attention,
.ui-editor-status-attention::after {
animation: none;
}
.ui-editor-status-attention::after {
opacity: 0.7;
transform: scale(1.01);
}
}
.client-auth-shell {
display: grid;
min-height: 100vh;
@@ -300,7 +300,10 @@ function NodeInspector({
keepChildrenUnchanged: boolean;
onKeepChildrenUnchangedChange: (value: boolean) => void;
onMetadataChange: UiEditorPageController['setNodeMetadata'];
highlightedStatusField: StageStatusField | null;
highlightedStatusField: {
field: StageStatusField;
requestId: number;
} | null;
onTransformChange: UiEditorPageController['setNodeTransform'];
onLayoutChange: UiEditorPageController['setNodeLayout'];
sprites: UiEditorPageController['sprites'];
@@ -406,7 +409,11 @@ function NodeInspector({
label="布局状态"
value={node.metadata.layout_status}
disabled={isReadOnly}
highlighted={highlightedStatusField === 'layout_status'}
highlight={
highlightedStatusField?.field === 'layout_status'
? highlightedStatusField
: null
}
onChange={(layout_status) => {
if (!isReadOnly) onMetadataChange({ layout_status });
}}
@@ -415,7 +422,11 @@ function NodeInspector({
label="组件状态"
value={node.metadata.components_status}
disabled={isReadOnly}
highlighted={highlightedStatusField === 'components_status'}
highlight={
highlightedStatusField?.field === 'components_status'
? highlightedStatusField
: null
}
onChange={(components_status) => {
if (!isReadOnly) onMetadataChange({ components_status });
}}
@@ -779,13 +790,13 @@ function NodeStageSelect({
label,
value,
disabled,
highlighted,
highlight,
onChange,
}: {
label: string;
value: SelectedNode['metadata']['layout_status'];
disabled: boolean;
highlighted: boolean;
highlight: { requestId: number } | null;
onChange: (value: SelectedNode['metadata']['layout_status']) => void;
}) {
const readOnly = useInspectorReadOnly();
@@ -801,11 +812,15 @@ function NodeStageSelect({
? value.NeedReview
: value.Blocked
: null;
const attentionTone = kind === 'Blocked' ? 'blocked' : 'review';
return (
<label
key={highlight?.requestId}
data-status-attention={highlight ? attentionTone : undefined}
data-status-attention-request={highlight?.requestId}
className={`block rounded-lg p-2 text-[10px] text-(--platform-text-soft) transition ${
highlighted
? 'border-2 border-orange-400 bg-orange-50 shadow-[0_0_0_3px_rgb(251_146_60_/_0.2)]'
highlight
? `ui-editor-status-attention ui-editor-status-attention--${attentionTone}`
: 'border border-transparent'
}`}
>
@@ -61,6 +61,11 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus';
const ASSET_BATCH_SIZE = 5;
type StatusFieldHighlight = {
field: StageStatusField;
requestId: number;
};
function findUiNodeLocation(
node: UiNode,
nodeId: NodeId,
@@ -133,7 +138,7 @@ export function useUiEditorPage(
);
const [selectedNodeId, setSelectedNodeId] = useState<NodeId | null>(null);
const [highlightedStatusField, setHighlightedStatusField] =
useState<StageStatusField | null>(null);
useState<StatusFieldHighlight | null>(null);
// NodeId 在 State 内全局唯一;隐藏状态直接记录节点 ID,跨树移动时随节点保留。
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<NodeId>>(
() => new Set(),
@@ -637,7 +642,10 @@ export function useUiEditorPage(
}
function highlightStatusField(field: StageStatusField) {
setHighlightedStatusField(field);
setHighlightedStatusField((current) => ({
field,
requestId: (current?.requestId ?? 0) + 1,
}));
}
function clearNodeSelection() {
@@ -306,7 +306,16 @@ describe('UiEditorPage', () => {
result.current.selectNode(rootId);
result.current.highlightStatusField('layout_status');
});
expect(result.current.highlightedStatusField).toBe('layout_status');
expect(result.current.highlightedStatusField).toMatchObject({
field: 'layout_status',
requestId: 1,
});
act(() => result.current.highlightStatusField('layout_status'));
expect(result.current.highlightedStatusField).toMatchObject({
field: 'layout_status',
requestId: 2,
});
act(() => result.current.selectNode(otherNodeId!));
expect(result.current.highlightedStatusField).toBeNull();