fix: 优化主页卡片到弹窗过渡与遮罩动画

This commit is contained in:
JSR
2026-04-17 20:46:16 +08:00
parent 2ef088def5
commit 0bcd41a704
2 changed files with 363 additions and 11 deletions
+74
View File
@@ -229,6 +229,80 @@ html.platform-macos #root {
overflow: hidden; overflow: hidden;
} }
.ss-reward-morph-modal {
--ss-modal-from-x: 0px;
--ss-modal-from-y: 0px;
--ss-modal-scale-x: 1;
--ss-modal-scale-y: 1;
}
.ss-reward-morph-modal.is-preparing {
opacity: 0;
}
.ss-reward-morph-modal .ant-modal-content {
transform-origin: center;
will-change: transform, opacity;
}
.ss-reward-morph-wrap.ss-reward-noop-motion-enter,
.ss-reward-morph-wrap.ss-reward-noop-motion-appear,
.ss-reward-morph-wrap.ss-reward-noop-motion-leave,
.ss-reward-morph-wrap.ss-reward-noop-motion-enter-active,
.ss-reward-morph-wrap.ss-reward-noop-motion-appear-active,
.ss-reward-morph-wrap.ss-reward-noop-motion-leave-active,
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-enter,
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-appear,
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-leave,
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-enter-active,
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-appear-active,
.ss-reward-morph-root .ss-reward-morph-modal.ss-reward-noop-motion-leave-active {
animation: none !important;
transition: none !important;
}
.ss-reward-morph-modal.is-enter .ant-modal-content {
animation: ss-reward-modal-morph-in 300ms cubic-bezier(0.2, 0.82, 0.22, 1) both;
}
.ss-home-operation-morph-modal .ant-modal-content {
transform-origin: center;
will-change: transform, opacity;
}
.ss-operation-mask-fade-enter,
.ss-operation-mask-fade-appear {
opacity: 0 !important;
}
.ss-operation-mask-fade-enter-active,
.ss-operation-mask-fade-appear-active {
opacity: 1 !important;
transition: opacity 260ms cubic-bezier(0.2, 0, 0, 1) !important;
}
.ss-operation-mask-fade-leave {
opacity: 1 !important;
}
.ss-operation-mask-fade-leave-active {
opacity: 0 !important;
transition: opacity 220ms cubic-bezier(0.4, 0, 1, 1) !important;
}
@keyframes ss-reward-modal-morph-in {
from {
transform: translate3d(var(--ss-modal-from-x), var(--ss-modal-from-y), 0)
scale(var(--ss-modal-scale-x), var(--ss-modal-scale-y));
opacity: 0.94;
}
to {
transform: translate3d(0, 0, 0) scale(1, 1);
opacity: 1;
}
}
.ss-immersive-sidebar { .ss-immersive-sidebar {
--ss-sidebar-width: 200px; --ss-sidebar-width: 200px;
height: 100%; height: 100%;
+289 -11
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react" import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from "react"
import { import {
Card, Card,
Space, Space,
@@ -127,6 +127,7 @@ export const Home: React.FC<HomeProps> = ({
const [batchMode, setBatchMode] = useState(false) const [batchMode, setBatchMode] = useState(false)
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]) const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([])
const [operationVisible, setOperationVisible] = useState(false) const [operationVisible, setOperationVisible] = useState(false)
const [operationOriginRect, setOperationOriginRect] = useState<DOMRect | null>(null)
const [customScore, setCustomScore] = useState<number | undefined>(undefined) const [customScore, setCustomScore] = useState<number | undefined>(undefined)
const [reasonContent, setReasonContent] = useState("") const [reasonContent, setReasonContent] = useState("")
const [submitLoading, setSubmitLoading] = useState(false) const [submitLoading, setSubmitLoading] = useState(false)
@@ -141,6 +142,13 @@ export const Home: React.FC<HomeProps> = ({
const longPressTimerRef = useRef<number | null>(null) const longPressTimerRef = useRef<number | null>(null)
const suppressClickRef = useRef(false) const suppressClickRef = useRef(false)
const fetchRequestIdRef = useRef(0) const fetchRequestIdRef = useRef(0)
const operationMorphAnimationRef = useRef<Animation | null>(null)
const operationMaskAnimationRef = useRef<Animation | null>(null)
const operationMorphRafRef = useRef<number | null>(null)
const operationClosingRef = useRef(false)
const operationCloseTokenRef = useRef(0)
const operationModalRootClass = "ss-home-operation-morph-root"
const operationModalClass = "ss-home-operation-morph-modal"
const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => {
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
@@ -589,7 +597,7 @@ export const Home: React.FC<HomeProps> = ({
} }
} }
const openOperation = (student: student) => { const openOperation = (student: student, sourceEl?: HTMLElement | null) => {
if (!canEdit) { if (!canEdit) {
messageApi.error(t("common.readOnly")) messageApi.error(t("common.readOnly"))
return return
@@ -605,12 +613,277 @@ export const Home: React.FC<HomeProps> = ({
) )
return return
} }
const cardEl = sourceEl?.querySelector(".ant-card") as HTMLElement | null
const sourceRect = (cardEl ?? sourceEl)?.getBoundingClientRect() ?? null
logHome("operation:morph:open", {
student: student.name,
hasSourceEl: Boolean(sourceEl),
hasCardEl: Boolean(cardEl),
sourceRect: sourceRect
? {
left: sourceRect.left,
top: sourceRect.top,
width: sourceRect.width,
height: sourceRect.height,
}
: null,
})
setOperationOriginRect(sourceRect)
operationClosingRef.current = false
operationCloseTokenRef.current += 1
operationMorphAnimationRef.current?.cancel()
operationMaskAnimationRef.current?.cancel()
setSelectedStudent(student) setSelectedStudent(student)
setCustomScore(undefined) setCustomScore(undefined)
setReasonContent("") setReasonContent("")
setOperationVisible(true) setOperationVisible(true)
} }
const playOperationMorph = useCallback((attempt = 0) => {
if (isPortraitMode || !operationOriginRect) return false
const modalEl = document.querySelector(`.${operationModalClass}`) as HTMLElement | null
if (!modalEl) {
const byClassCount = document.querySelectorAll(`.${operationModalClass}`).length
const byRootCount = document.querySelectorAll(`.${operationModalRootClass}`).length
const modalCount = document.querySelectorAll(".ant-modal").length
const rootExists = Boolean(document.querySelector(`.${operationModalRootClass}`))
logHome("operation:morph:modal-miss", {
attempt,
rootExists,
byClassCount,
byRootCount,
modalCount,
})
return false
}
// Clear any leftover transform from previous close animation before measuring.
operationMorphAnimationRef.current?.cancel()
for (const animation of modalEl.getAnimations()) {
animation.cancel()
}
modalEl.style.transform = ""
modalEl.style.opacity = ""
modalEl.style.visibility = ""
const maskEl = document.querySelector(`.${operationModalRootClass} .ant-modal-mask`) as
| HTMLElement
| null
if (maskEl) {
operationMaskAnimationRef.current?.cancel()
maskEl.style.opacity = ""
maskEl.style.visibility = ""
operationMaskAnimationRef.current = maskEl.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: 220,
easing: "cubic-bezier(0.2, 0, 0, 1)",
fill: "both",
})
}
const modalRect = modalEl.getBoundingClientRect()
if (modalRect.width <= 0 || modalRect.height <= 0) {
logHome("operation:morph:modal-rect-invalid", {
attempt,
width: modalRect.width,
height: modalRect.height,
})
return false
}
const fromX = operationOriginRect.left - modalRect.left
const fromY = operationOriginRect.top - modalRect.top
const scaleX = operationOriginRect.width / modalRect.width
const scaleY = operationOriginRect.height / modalRect.height
logHome("operation:morph:computed", {
modalRect: {
left: modalRect.left,
top: modalRect.top,
width: modalRect.width,
height: modalRect.height,
},
fromX,
fromY,
scaleX,
scaleY,
expectedStartRect: {
left: modalRect.left + fromX,
top: modalRect.top + fromY,
width: modalRect.width * scaleX,
height: modalRect.height * scaleY,
},
animateSupported: typeof modalEl.animate === "function",
})
modalEl.style.transformOrigin = "top left"
modalEl.style.willChange = "transform, opacity"
operationMorphAnimationRef.current = modalEl.animate(
[
{
transform: `translate3d(${fromX}px, ${fromY}px, 0) scale(${scaleX}, ${scaleY})`,
opacity: 0.86,
},
{
transform: "translate3d(0, 0, 0) scale(1, 1)",
opacity: 1,
},
],
{
duration: 480,
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
fill: "both",
}
)
logHome("operation:morph:animation-start", {
attempt,
currentTime: operationMorphAnimationRef.current.currentTime,
playState: operationMorphAnimationRef.current.playState,
})
operationMorphAnimationRef.current.onfinish = () => {
logHome("operation:morph:animation-finish")
}
operationMorphAnimationRef.current.oncancel = () => {
logHome("operation:morph:animation-cancel")
}
return true
}, [isPortraitMode, operationOriginRect, operationModalClass, operationModalRootClass])
useLayoutEffect(() => {
if (isPortraitMode || !operationVisible) return
logHome("operation:morph:layout-open", {
hasOriginRect: Boolean(operationOriginRect),
})
let attempt = 0
const maxAttempts = 120
const run = () => {
operationMorphRafRef.current = null
const done = playOperationMorph(attempt)
if (done) return
if (attempt >= maxAttempts) {
logHome("operation:morph:retry-exhausted", { attempts: attempt + 1 })
return
}
attempt += 1
operationMorphRafRef.current = window.requestAnimationFrame(run)
}
operationMorphRafRef.current = window.requestAnimationFrame(run)
return () => {
if (operationMorphRafRef.current !== null) {
window.cancelAnimationFrame(operationMorphRafRef.current)
operationMorphRafRef.current = null
}
}
}, [isPortraitMode, operationVisible, operationOriginRect, playOperationMorph])
const finishCloseOperationModal = useCallback((skipCancelMorph = false, skipCancelMask = false) => {
if (!skipCancelMorph) {
operationMorphAnimationRef.current?.cancel()
}
operationMorphAnimationRef.current = null
if (!skipCancelMask) {
operationMaskAnimationRef.current?.cancel()
}
operationMaskAnimationRef.current = null
if (operationMorphRafRef.current !== null) {
window.cancelAnimationFrame(operationMorphRafRef.current)
operationMorphRafRef.current = null
}
operationClosingRef.current = false
setOperationVisible(false)
setOperationOriginRect(null)
}, [])
const closeOperationModal = useCallback(() => {
logHome("operation:morph:close", {
hasAnimation: Boolean(operationMorphAnimationRef.current),
playState: operationMorphAnimationRef.current?.playState,
currentTime: operationMorphAnimationRef.current?.currentTime,
})
if (operationMorphRafRef.current !== null) {
window.cancelAnimationFrame(operationMorphRafRef.current)
operationMorphRafRef.current = null
}
if (
!isPortraitMode &&
operationVisible &&
operationOriginRect &&
!operationClosingRef.current
) {
const modalEl = document.querySelector(`.${operationModalClass}`) as HTMLElement | null
if (modalEl) {
const modalRect = modalEl.getBoundingClientRect()
if (modalRect.width > 0 && modalRect.height > 0) {
const toX = operationOriginRect.left - modalRect.left
const toY = operationOriginRect.top - modalRect.top
const toScaleX = operationOriginRect.width / modalRect.width
const toScaleY = operationOriginRect.height / modalRect.height
const closeToken = ++operationCloseTokenRef.current
operationClosingRef.current = true
operationMorphAnimationRef.current?.cancel()
operationMaskAnimationRef.current?.cancel()
modalEl.style.transformOrigin = "top left"
modalEl.style.willChange = "transform, opacity"
const maskEl = document.querySelector(
`.${operationModalRootClass} .ant-modal-mask`
) as HTMLElement | null
if (maskEl) {
operationMaskAnimationRef.current = maskEl.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 320,
easing: "cubic-bezier(0.4, 0, 1, 1)",
fill: "both",
})
}
operationMorphAnimationRef.current = modalEl.animate(
[
{ transform: "translate3d(0, 0, 0) scale(1, 1)", opacity: 1 },
{
transform: `translate3d(${toX}px, ${toY}px, 0) scale(${toScaleX}, ${toScaleY})`,
opacity: 0,
},
],
{
duration: 320,
easing: "cubic-bezier(0.4, 0, 1, 1)",
fill: "both",
}
)
operationMorphAnimationRef.current.onfinish = () => {
if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) {
logHome("operation:morph:close-animation-finish:stale", { closeToken })
return
}
logHome("operation:morph:close-animation-finish")
modalEl.style.opacity = "0"
modalEl.style.visibility = "hidden"
if (maskEl) {
maskEl.style.opacity = "0"
maskEl.style.visibility = "hidden"
}
finishCloseOperationModal(true, true)
}
operationMorphAnimationRef.current.oncancel = () => {
if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) {
logHome("operation:morph:close-animation-cancel:stale", { closeToken })
return
}
logHome("operation:morph:close-animation-cancel")
finishCloseOperationModal()
}
return
}
}
}
finishCloseOperationModal()
}, [
finishCloseOperationModal,
isPortraitMode,
operationModalClass,
operationOriginRect,
operationVisible,
])
const handleToggleRewardMode = () => { const handleToggleRewardMode = () => {
if (!canEdit) { if (!canEdit) {
messageApi.error(t("common.readOnly")) messageApi.error(t("common.readOnly"))
@@ -623,7 +896,7 @@ export const Home: React.FC<HomeProps> = ({
setRewardMode((prev) => !prev) setRewardMode((prev) => !prev)
setBatchMode(false) setBatchMode(false)
setSelectedStudentIds([]) setSelectedStudentIds([])
setOperationVisible(false) closeOperationModal()
setQuickActionStudentId(null) setQuickActionStudentId(null)
setRewardStudent(null) setRewardStudent(null)
setRewardModalVisible(false) setRewardModalVisible(false)
@@ -717,7 +990,7 @@ export const Home: React.FC<HomeProps> = ({
setSelectedStudentIds([]) setSelectedStudentIds([])
setBatchMode(false) setBatchMode(false)
setSelectedStudent(null) setSelectedStudent(null)
setOperationVisible(false) closeOperationModal()
setCustomScore(undefined) setCustomScore(undefined)
setReasonContent("") setReasonContent("")
setQuickActionStudentId(null) setQuickActionStudentId(null)
@@ -846,7 +1119,7 @@ export const Home: React.FC<HomeProps> = ({
} }
setBatchMode(true) setBatchMode(true)
setSelectedStudent(null) setSelectedStudent(null)
setOperationVisible(false) closeOperationModal()
setQuickActionStudentId(null) setQuickActionStudentId(null)
} }
@@ -895,7 +1168,7 @@ export const Home: React.FC<HomeProps> = ({
e.stopPropagation() e.stopPropagation()
return return
} }
openOperation(student) openOperation(student, e.currentTarget as HTMLElement)
}} }}
onMouseDown={(e) => { onMouseDown={(e) => {
if (batchMode) return if (batchMode) return
@@ -1113,7 +1386,7 @@ export const Home: React.FC<HomeProps> = ({
e.stopPropagation() e.stopPropagation()
return return
} }
openOperation(student) openOperation(student, e.currentTarget as HTMLElement)
}} }}
onMouseDown={(e) => { onMouseDown={(e) => {
if (batchMode) return if (batchMode) return
@@ -1280,7 +1553,7 @@ export const Home: React.FC<HomeProps> = ({
e.stopPropagation() e.stopPropagation()
return return
} }
openOperation(student) openOperation(student, e.currentTarget as HTMLElement)
}} }}
onMouseDown={(e) => { onMouseDown={(e) => {
if (batchMode) return if (batchMode) return
@@ -2705,7 +2978,7 @@ export const Home: React.FC<HomeProps> = ({
placement="bottom" placement="bottom"
height="100%" height="100%"
open={operationVisible} open={operationVisible}
onClose={() => setOperationVisible(false)} onClose={closeOperationModal}
afterOpenChange={applyDrawerDragRegion} afterOpenChange={applyDrawerDragRegion}
destroyOnClose destroyOnClose
styles={{ styles={{
@@ -2713,7 +2986,7 @@ export const Home: React.FC<HomeProps> = ({
}} }}
footer={ footer={
<Space style={{ width: "100%", justifyContent: "flex-end" }}> <Space style={{ width: "100%", justifyContent: "flex-end" }}>
<Button onClick={() => setOperationVisible(false)}>{t("common.cancel")}</Button> <Button onClick={closeOperationModal}>{t("common.cancel")}</Button>
<Button type="primary" onClick={handleSubmit} loading={submitLoading}> <Button type="primary" onClick={handleSubmit} loading={submitLoading}>
{t("home.submitOperation")} {t("home.submitOperation")}
</Button> </Button>
@@ -2730,13 +3003,18 @@ export const Home: React.FC<HomeProps> = ({
: t("home.operationTitle", { name: selectedStudent?.name }) : t("home.operationTitle", { name: selectedStudent?.name })
} }
open={operationVisible} open={operationVisible}
onCancel={() => setOperationVisible(false)} onCancel={closeOperationModal}
onOk={handleSubmit} onOk={handleSubmit}
confirmLoading={submitLoading} confirmLoading={submitLoading}
okText={t("home.submitOperation")} okText={t("home.submitOperation")}
cancelText={t("common.cancel")} cancelText={t("common.cancel")}
width={560} width={560}
centered centered
forceRender
transitionName=""
maskTransitionName=""
rootClassName={operationModalRootClass}
className={operationModalClass}
styles={{ styles={{
body: { body: {
maxHeight: "calc(100vh - 220px)", maxHeight: "calc(100vh - 220px)",