diff --git a/src/assets/main.css b/src/assets/main.css index afe0dba..02058c4 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -263,9 +263,406 @@ html.platform-macos body { animation: ss-reward-modal-morph-in 300ms cubic-bezier(0.2, 0.82, 0.22, 1) both; } +.ss-home-operation-morph-modal .ant-modal-container, .ss-home-operation-morph-modal .ant-modal-content { transform-origin: center; - will-change: transform, opacity; + will-change: transform, width, height; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--ss-border-color) 85%, white); + border-radius: 20px; + box-shadow: 0 28px 80px rgba(26, 44, 77, 0.22); +} + +.ss-home-operation-morph-modal .ant-modal-header { + padding: 18px 22px 10px; + margin: 0; + background: transparent; +} + +.ss-home-operation-morph-modal .ant-modal-title { + font-size: 18px; + font-weight: 750; +} + +.ss-home-operation-morph-modal .ant-modal-body { + padding: 10px 22px 16px; +} + +.ss-home-operation-morph-modal .ant-modal-footer { + margin: 0; + padding: 12px 22px 18px; + border-top: 1px solid color-mix(in srgb, var(--ss-border-color) 78%, transparent); +} + +.ss-home-operation-morph-modal .ant-modal-header, +.ss-home-operation-morph-modal .ant-modal-footer { + display: none; +} + +.ss-home-operation-morph-modal .ant-modal-content { + padding: 0 !important; + border-radius: 18px; + background: #fff; +} + +.ss-home-operation-morph-modal.ss-operation-morph-active .ant-modal-container, +.ss-home-operation-morph-modal.ss-operation-morph-active .ant-modal-content { + animation: ss-operation-shell-in 620ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.ss-home-operation-morph-modal.ss-operation-morph-closing .ant-modal-container, +.ss-home-operation-morph-modal.ss-operation-morph-closing .ant-modal-content { + animation: ss-operation-shell-out 460ms cubic-bezier(0.4, 0, 0.2, 1) both; +} + +@keyframes ss-operation-shell-in { + 0%, 28% { + background-color: #fff; + border-color: var(--ss-border-color); + box-shadow: none; + } + + 62%, 100% { + background-color: #fff; + border-color: #dcdcdc; + box-shadow: 0 28px 80px rgba(26, 44, 77, 0.22); + } +} + +@keyframes ss-operation-shell-out { + 0% { + background-color: #fff; + border-color: #dcdcdc; + box-shadow: 0 28px 80px rgba(26, 44, 77, 0.22); + } + + 100% { + background-color: #fff; + border-color: var(--ss-border-color); + box-shadow: none; + } +} + +.ss-home-operation-morph-modal.ss-operation-morph-active .ss-operation-designed-score-label, +.ss-home-operation-morph-modal.ss-operation-morph-active .ss-operation-designed-close { + animation: ss-operation-meta-in 360ms 230ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.ss-home-operation-morph-modal.ss-operation-morph-active .ss-operation-designed-quick-buttons, +.ss-home-operation-morph-modal.ss-operation-morph-active .ss-operation-designed-divider, +.ss-home-operation-morph-modal.ss-operation-morph-active .ss-operation-designed-reasons { + animation: ss-operation-body-in 400ms 300ms cubic-bezier(0.2, 0, 0, 1) both; +} + +.ss-home-operation-morph-modal.ss-operation-morph-closing .ss-operation-designed-score-label, +.ss-home-operation-morph-modal.ss-operation-morph-closing .ss-operation-designed-close, +.ss-home-operation-morph-modal.ss-operation-morph-closing .ss-operation-designed-quick-buttons, +.ss-home-operation-morph-modal.ss-operation-morph-closing .ss-operation-designed-divider, +.ss-home-operation-morph-modal.ss-operation-morph-closing .ss-operation-designed-reasons { + animation: ss-operation-content-out 180ms cubic-bezier(0.4, 0, 1, 1) both; +} + +@keyframes ss-operation-meta-in { + 0% { + opacity: 0; + filter: blur(10px); + transform: translateY(8px); + } + + 100% { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } +} + +@keyframes ss-operation-body-in { + 0% { + opacity: 0; + filter: blur(8px); + transform: translateY(14px); + } + + 100% { + opacity: 1; + filter: blur(0); + transform: translateY(0); + } +} + +@keyframes ss-operation-content-out { + 0% { + opacity: 1; + filter: blur(0); + } + + 100% { + opacity: 0; + filter: blur(8px); + } +} + +.ss-operation-panel-designed { + gap: 0; + padding: 0; + color: #202020; +} + +.ss-operation-designed-header { + display: flex; + align-items: center; + min-height: 82px; + gap: 12px; + padding: 14px 2px 12px; +} + +.ss-operation-designed-student { + display: flex; + align-items: center; + min-width: 0; + gap: 12px; + flex: 1; +} + +.ss-operation-designed-student img, +.ss-operation-designed-avatar-fallback { + width: 54px; + height: 54px; + flex: 0 0 54px; + border-radius: 50%; + object-fit: cover; +} + +.ss-operation-designed-avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 18px; + font-weight: 700; +} + +.ss-operation-designed-student > span { + overflow: hidden; + color: #111; + font-size: 22px; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ss-operation-designed-score { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + color: #666; + font-size: 18px; +} + +.ss-operation-designed-score .ant-tag { + margin: 0; + padding: 3px 8px; + border: 0; + border-radius: 10px; + font-size: 17px; + font-weight: 750; +} + +.ss-operation-designed-close { + width: 38px; + height: 38px; + flex: 0 0 38px; + padding: 0; + color: #111 !important; + font-size: 34px; + font-weight: 300; + line-height: 1; +} + +.ss-operation-designed-quick-buttons { + display: grid; + grid-template-columns: repeat(9, minmax(0, 1fr)); + gap: 8px; + padding: 18px 0 14px; +} + +.ss-operation-designed-quick-buttons .ant-btn { + min-width: 0; + height: 68px; + padding: 0 6px; + border: 0; + border-radius: 60px; + color: #262626; + font-size: 32px; + font-weight: 800; + line-height: 1; +} + +.ss-operation-designed-quick-buttons .ant-btn:nth-child(1) { background: #ff8083; color: #fff; } +.ss-operation-designed-quick-buttons .ant-btn:nth-child(2) { background: #ff9fa2; color: #fff; } +.ss-operation-designed-quick-buttons .ant-btn:nth-child(3) { background: #ffc4c5; color: #f24d51; } +.ss-operation-designed-quick-buttons .ant-btn:nth-child(4) { background: #ffdfe0; color: #f21c22; } +.ss-operation-designed-quick-buttons .ant-btn:nth-child(5) { background: #e9fff0; } +.ss-operation-designed-quick-buttons .ant-btn:nth-child(6) { background: #d0ffdc; } +.ss-operation-designed-quick-buttons .ant-btn:nth-child(7) { background: #b9fbc9; } +.ss-operation-designed-quick-buttons .ant-btn:nth-child(8) { background: #a2f7b8; } +.ss-operation-designed-quick-buttons .ant-btn:nth-child(9) { background: #8cf2a8; } + +.ss-operation-designed-divider { + height: 5px; + margin: 0 -4px 14px; + border-radius: 8px; + background: #d8d8d8; +} + +.ss-operation-designed-reasons { + display: flex; + flex-direction: column; + gap: 28px; + padding: 0 8px 16px; +} + +.ss-operation-designed-category-title { + margin-bottom: 14px; + color: #666; + font-size: 18px; + font-weight: 500; +} + +.ss-operation-designed-reason-buttons { + display: flex; + flex-wrap: wrap; + gap: 12px 14px; +} + +.ss-operation-designed-reason-buttons .ant-btn { + min-width: 132px; + min-height: 50px; + padding: 6px 16px; + border-width: 2px; + border-radius: 12px; + background: #fff; + font-size: 17px; +} + +.ss-operation-designed-reason-buttons .ant-btn.is-positive { + border-color: #55c936; +} + +.ss-operation-designed-reason-buttons .ant-btn.is-negative { + border-color: #ff4d55; +} + +.ss-operation-designed-reason-buttons .ant-btn > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ss-operation-designed-reason-buttons .ant-btn strong { + margin-left: 12px; + font-weight: 750; +} + +.ss-operation-designed-reason-buttons .ant-btn.is-positive strong { color: #48bd2e; } +.ss-operation-designed-reason-buttons .ant-btn.is-negative strong { color: #ff4d55; } + +.ss-operation-panel-header { + border: 1px solid color-mix(in srgb, var(--ss-border-color) 82%, white); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.72); +} + +.ss-operation-panel-student-name { + font-size: 16px; + letter-spacing: 0.01em; +} + +.ss-operation-panel-score .ant-tag { + margin-inline-end: 0; + border: 0; + border-radius: 999px; + padding: 3px 10px; + font-size: 15px; +} + +.ss-operation-quick-strip { + padding: 14px 16px 16px; + border: 1px solid color-mix(in srgb, var(--ss-border-color) 76%, white); + border-radius: 16px; + background: linear-gradient(145deg, color-mix(in srgb, var(--ss-card-bg) 90%, #edf6ff), var(--ss-card-bg)); +} + +.ss-operation-quick-strip-label { + margin-bottom: 10px; + color: var(--ss-text-secondary); + font-size: 12px; + font-weight: 650; + letter-spacing: 0.04em; +} + +.ss-operation-quick-buttons { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 9px; +} + +.ss-operation-quick-buttons .ant-btn { + min-width: 0; + height: 48px; + padding: 0 8px; + border: 0; + border-radius: 999px; + color: #263238; + font-size: 17px; + font-weight: 800; + box-shadow: none; +} + +.ss-operation-quick-buttons .ant-btn:nth-child(-n + 3) { + background: #ffe0e0; + color: #d94848; +} + +.ss-operation-quick-buttons .ant-btn:nth-child(1) { + background: #ff8b8b; + color: white; +} + +.ss-operation-quick-buttons .ant-btn:nth-child(2) { + background: #ffabab; + color: white; +} + +.ss-operation-quick-buttons .ant-btn:nth-child(n + 4) { + background: #e6fff0; +} + +.ss-operation-quick-buttons .ant-btn:nth-child(5) { + background: #cef8dc; +} + +.ss-operation-quick-buttons .ant-btn:nth-child(6) { + background: #b6f3ca; +} + +.ss-operation-quick-buttons .ant-btn:nth-child(7) { + background: #a5efbd; +} + +.ss-operation-quick-buttons .ant-btn:nth-child(8) { + background: #91eab0; +} + +.ss-operation-panel-section { + padding: 16px; + border: 1px solid color-mix(in srgb, var(--ss-border-color) 75%, white); + border-radius: 16px; + background: color-mix(in srgb, var(--ss-card-bg) 94%, #f7fbff); +} + +.ss-operation-section-title > span { + font-size: 15px !important; } .ss-home-operation-morph-modal .ss-operation-panel-section, @@ -302,6 +699,29 @@ html.platform-macos body { font-weight: 700; } +@media (max-width: 860px) { + .ss-operation-quick-buttons { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .ss-operation-designed-quick-buttons { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + } + + .ss-operation-designed-quick-buttons .ant-btn { + height: 72px; + } + + .ss-operation-designed-score { + font-size: 18px; + } + + .ss-operation-designed-score .ant-tag { + font-size: 18px; + } +} + @media (max-width: 768px) { .ss-operation-panel-grid { grid-template-columns: 1fr !important; @@ -315,26 +735,54 @@ html.platform-macos body { .ss-reason-preset-buttons .ant-btn { min-height: 46px; } -} -.ss-operation-mask-fade-enter, -.ss-operation-mask-fade-appear { - opacity: 0 !important; -} + .ss-operation-quick-buttons { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } -.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-designed-header { + min-height: 84px; + gap: 8px; + padding: 14px 0; + } -.ss-operation-mask-fade-leave { - opacity: 1 !important; -} + .ss-operation-designed-student img, + .ss-operation-designed-avatar-fallback { + width: 58px; + height: 58px; + flex-basis: 58px; + } -.ss-operation-mask-fade-leave-active { - opacity: 0 !important; - transition: opacity 220ms cubic-bezier(0.4, 0, 1, 1) !important; + .ss-operation-designed-student > span { + font-size: 21px; + } + + .ss-operation-designed-close { + width: 38px; + height: 38px; + flex-basis: 38px; + font-size: 36px; + } + + .ss-operation-designed-quick-buttons { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .ss-operation-designed-quick-buttons .ant-btn { + height: 64px; + font-size: 26px; + } + + .ss-operation-designed-category-title { + font-size: 19px; + } + + .ss-operation-designed-reason-buttons .ant-btn { + min-width: 0; + min-height: 56px; + padding: 6px 14px; + font-size: 18px; + } } @keyframes ss-reward-modal-morph-in { diff --git a/src/components/Home.tsx b/src/components/Home.tsx index 2c78462..1e551e0 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -64,6 +64,35 @@ interface rewardSetting { cost_points: number } +interface operationMorphElementRects { + avatar: DOMRect | null + name: DOMRect | null + score: DOMRect | null + avatarBorderRadius: string | null + surfaceBorderRadius: string | null +} + +const getOperationElementMorph = ( + targetRect: DOMRect, + sourceRect: DOMRect +) => { + const translateX = sourceRect.left - targetRect.left + const translateY = sourceRect.top - targetRect.top + const scale = sourceRect.height / Math.max(1, targetRect.height) + return { + transform: `translate3d(${translateX}px, ${translateY}px, 0) scale(${scale})`, + translateX, + translateY, + scale, + } +} + +const getCompensatedBorderRadius = (borderRadius: string, scale: number) => { + const value = Number.parseFloat(borderRadius) + if (!Number.isFinite(value) || scale <= 0) return borderRadius + return `${value / scale}px` +} + type SortType = "alphabet" | "surname" | "group" | "score" type LayoutType = "grouped" | "squareGrid" | "largeAvatar" type SearchKeyboardLayout = "t9" | "qwerty26" @@ -138,6 +167,8 @@ export const Home: React.FC = ({ const [selectedStudentIds, setSelectedStudentIds] = useState([]) const [operationVisible, setOperationVisible] = useState(false) const [operationOriginRect, setOperationOriginRect] = useState(null) + const [operationOriginElements, setOperationOriginElements] = + useState(null) const [customScore, setCustomScore] = useState(undefined) const [reasonContent, setReasonContent] = useState("") const [submitLoading, setSubmitLoading] = useState(false) @@ -163,11 +194,16 @@ export const Home: React.FC = ({ const fetchRequestIdRef = useRef(0) const operationMorphAnimationRef = useRef(null) const operationMaskAnimationRef = useRef(null) + const operationElementAnimationsRef = useRef([]) const operationMorphRafRef = useRef(null) const operationClosingRef = useRef(false) const operationCloseTokenRef = useRef(0) const operationModalRootClass = "ss-home-operation-morph-root" const operationModalClass = "ss-home-operation-morph-modal" + const operationMorphOpenDuration = 620 + const operationMorphCloseDuration = 460 + const operationMorphEasing = "cubic-bezier(0.2, 0, 0, 1)" + const operationMorphCloseEasing = "cubic-bezier(0.4, 0, 0.2, 1)" const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) @@ -176,9 +212,9 @@ export const Home: React.FC = ({ const logHome = (message: string, meta?: Record) => { try { if (meta) { - console.error(`[Home][Diag] ${message}`, meta) + console.info(`[Home][积分弹窗] ${message}`, meta) } else { - console.error(`[Home][Diag] ${message}`) + console.info(`[Home][积分弹窗] ${message}`) } } catch { void 0 @@ -642,11 +678,45 @@ export const Home: React.FC = ({ } const cardEl = sourceEl?.querySelector(".ant-card") as HTMLElement | null - const sourceRect = (cardEl ?? sourceEl)?.getBoundingClientRect() ?? null + const cardBodyEl = sourceEl?.querySelector(".ant-card-body") as HTMLElement | null + const sourceRect = (cardBodyEl ?? cardEl ?? sourceEl)?.getBoundingClientRect() ?? null + const findLeafByText = (text: string) => { + if (!sourceEl) return null + return ( + Array.from(sourceEl.querySelectorAll("div, span")) + .filter((element) => element.children.length === 0 && element.textContent?.trim() === text) + .sort((a, b) => { + const aRect = a.getBoundingClientRect() + const bRect = b.getBoundingClientRect() + return aRect.width * aRect.height - bRect.width * bRect.height + })[0] ?? null + ) + } + const displayPoints = getDisplayPoints(student) + const scoreText = displayPoints > 0 ? `+${displayPoints}` : String(displayPoints) + const sourceAvatarEl = + (sourceEl?.querySelector('[data-operation-morph="avatar"]') as HTMLElement | null) ?? + (sourceEl?.querySelector(`img[alt="${CSS.escape(student.name)}"]`) as HTMLElement | null) + const sourceNameEl = + (sourceEl?.querySelector('[data-operation-morph="name"]') as HTMLElement | null) ?? + findLeafByText(student.name) + const sourceScoreEl = + (sourceEl?.querySelector('[data-operation-morph="score"]') as HTMLElement | null) ?? + findLeafByText(scoreText) + const sourceElements: operationMorphElementRects = { + avatar: sourceAvatarEl?.getBoundingClientRect() ?? null, + name: sourceNameEl?.getBoundingClientRect() ?? null, + score: sourceScoreEl?.getBoundingClientRect() ?? null, + avatarBorderRadius: sourceAvatarEl + ? window.getComputedStyle(sourceAvatarEl).borderRadius + : null, + surfaceBorderRadius: cardEl ? window.getComputedStyle(cardEl).borderRadius : null, + } logHome("operation:morph:open", { student: student.name, hasSourceEl: Boolean(sourceEl), hasCardEl: Boolean(cardEl), + hasCardBodyEl: Boolean(cardBodyEl), sourceRect: sourceRect ? { left: sourceRect.left, @@ -655,12 +725,22 @@ export const Home: React.FC = ({ height: sourceRect.height, } : null, + sourceElements: { + avatar: sourceElements.avatar?.toJSON() ?? null, + name: sourceElements.name?.toJSON() ?? null, + score: sourceElements.score?.toJSON() ?? null, + avatarBorderRadius: sourceElements.avatarBorderRadius, + surfaceBorderRadius: sourceElements.surfaceBorderRadius, + }, }) setOperationOriginRect(sourceRect) + setOperationOriginElements(sourceElements) operationClosingRef.current = false operationCloseTokenRef.current += 1 operationMorphAnimationRef.current?.cancel() operationMaskAnimationRef.current?.cancel() + operationElementAnimationsRef.current.forEach((animation) => animation.cancel()) + operationElementAnimationsRef.current = [] setSelectedStudent(student) setCustomScore(undefined) @@ -686,29 +766,54 @@ export const Home: React.FC = ({ }) return false } + const modalSurfaceEl = + (modalEl.querySelector(".ant-modal-container, .ant-modal-content") as HTMLElement | null) ?? + modalEl - // 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 = "" + logHome("operation:morph:surface-found", { + attempt, + surfaceTag: modalSurfaceEl.tagName, + surfaceClassName: modalSurfaceEl.className, + usesModalContainer: modalSurfaceEl.classList.contains("ant-modal-container"), + usesModalContent: modalSurfaceEl.classList.contains("ant-modal-content"), + }) const maskEl = document.querySelector( `.${operationModalRootClass} .ant-modal-mask` ) as HTMLElement | null if (maskEl) { operationMaskAnimationRef.current?.cancel() - maskEl.style.opacity = "" - maskEl.style.visibility = "" + maskEl.style.visibility = "visible" operationMaskAnimationRef.current = maskEl.animate([{ opacity: 0 }, { opacity: 1 }], { - duration: 220, - easing: "cubic-bezier(0.2, 0, 0, 1)", + duration: operationMorphOpenDuration, + easing: operationMorphEasing, fill: "both", }) + logHome("operation:morph:mask-fade-in", { + duration: operationMorphOpenDuration, + easing: operationMorphEasing, + keyframes: [0, 1], + }) + } else { + logHome("operation:morph:mask-miss") } - const modalRect = modalEl.getBoundingClientRect() + + // Animate the modal surface itself. Animating the outer .ant-modal only moves the + // layout box; it does not make the card body visually become the modal container. + operationMorphAnimationRef.current?.cancel() + for (const animation of modalSurfaceEl.getAnimations()) { + animation.cancel() + } + modalEl.classList.remove("ss-operation-morph-closing") + modalEl.classList.add("ss-operation-morph-active") + modalSurfaceEl.style.position = "" + modalSurfaceEl.style.left = "" + modalSurfaceEl.style.top = "" + modalSurfaceEl.style.width = "" + modalSurfaceEl.style.height = "" + modalSurfaceEl.style.margin = "" + modalSurfaceEl.style.transform = "" + modalSurfaceEl.style.visibility = "" + const modalRect = modalSurfaceEl.getBoundingClientRect() if (modalRect.width <= 0 || modalRect.height <= 0) { logHome("operation:morph:modal-rect-invalid", { attempt, @@ -717,11 +822,6 @@ export const Home: React.FC = ({ }) 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, @@ -729,35 +829,120 @@ export const Home: React.FC = ({ width: modalRect.width, height: modalRect.height, }, - fromX, - fromY, - scaleX, - scaleY, + deltaX: operationOriginRect.left - modalRect.left, + deltaY: operationOriginRect.top - modalRect.top, expectedStartRect: { - left: modalRect.left + fromX, - top: modalRect.top + fromY, - width: modalRect.width * scaleX, - height: modalRect.height * scaleY, + left: operationOriginRect.left, + top: operationOriginRect.top, + width: operationOriginRect.width, + height: operationOriginRect.height, }, animateSupported: typeof modalEl.animate === "function", + duration: operationMorphOpenDuration, + easing: operationMorphEasing, + animation: "ant-card-body 与 ant-modal-container 重合后,真实插值位置与宽高;遮罩同步淡入", }) - modalEl.style.transformOrigin = "top left" - modalEl.style.willChange = "transform, opacity" - operationMorphAnimationRef.current = modalEl.animate( + modalSurfaceEl.style.position = "fixed" + modalSurfaceEl.style.left = `${operationOriginRect.left}px` + modalSurfaceEl.style.top = `${operationOriginRect.top}px` + modalSurfaceEl.style.width = `${operationOriginRect.width}px` + modalSurfaceEl.style.height = `${operationOriginRect.height}px` + modalSurfaceEl.style.margin = "0" + modalSurfaceEl.style.transform = "none" + const positionedStartRect = modalSurfaceEl.getBoundingClientRect() + logHome("operation:morph:viewport-start", { + requested: operationOriginRect.toJSON(), + actual: positionedStartRect.toJSON(), + error: { + left: positionedStartRect.left - operationOriginRect.left, + top: positionedStartRect.top - operationOriginRect.top, + width: positionedStartRect.width - operationOriginRect.width, + height: positionedStartRect.height - operationOriginRect.height, + }, + }) + + operationElementAnimationsRef.current.forEach((animation) => animation.cancel()) + operationElementAnimationsRef.current = [] + const elementMorphTargets = [ + { + key: "avatar", + selector: ".ss-operation-designed-avatar", + sourceRect: operationOriginElements?.avatar ?? null, + }, + { + key: "name", + selector: ".ss-operation-designed-name", + sourceRect: operationOriginElements?.name ?? null, + }, + { + key: "score", + selector: ".ss-operation-designed-score-value", + sourceRect: operationOriginElements?.score ?? null, + }, + ] + const elementMorphLog: Record = {} + for (const item of elementMorphTargets) { + const targetEl = modalEl.querySelector(item.selector) as HTMLElement | null + if (!targetEl || !item.sourceRect) { + elementMorphLog[item.key] = { found: Boolean(targetEl), hasSourceRect: Boolean(item.sourceRect) } + continue + } + const targetRect = targetEl.getBoundingClientRect() + const morph = getOperationElementMorph(targetRect, item.sourceRect) + targetEl.style.transformOrigin = "top left" + const fromKeyframe: Keyframe = { transform: morph.transform } + const toKeyframe: Keyframe = { transform: "translate3d(0, 0, 0) scale(1, 1)" } + if (item.key === "avatar" && operationOriginElements?.avatarBorderRadius) { + fromKeyframe.borderRadius = getCompensatedBorderRadius( + operationOriginElements.avatarBorderRadius, + morph.scale + ) + toKeyframe.borderRadius = "50%" + } + const animation = targetEl.animate([fromKeyframe, toKeyframe], { + duration: operationMorphOpenDuration, + easing: operationMorphEasing, + fill: "both", + }) + operationElementAnimationsRef.current.push(animation) + elementMorphLog[item.key] = { + sourceRect: item.sourceRect.toJSON(), + targetRect: targetRect.toJSON(), + ...morph, + } + } + logHome("operation:morph:shared-elements-start", elementMorphLog) + + modalSurfaceEl.style.position = "fixed" + modalSurfaceEl.style.left = `${modalRect.left}px` + modalSurfaceEl.style.top = `${modalRect.top}px` + modalSurfaceEl.style.width = `${modalRect.width}px` + modalSurfaceEl.style.height = `${modalRect.height}px` + modalSurfaceEl.style.margin = "0" + modalSurfaceEl.style.transform = "none" + modalSurfaceEl.style.transformOrigin = "top left" + modalSurfaceEl.style.willChange = "left, top, width, height" + operationMorphAnimationRef.current = modalSurfaceEl.animate( [ { - transform: `translate3d(${fromX}px, ${fromY}px, 0) scale(${scaleX}, ${scaleY})`, - opacity: 0.86, + left: `${operationOriginRect.left}px`, + top: `${operationOriginRect.top}px`, + width: `${operationOriginRect.width}px`, + height: `${operationOriginRect.height}px`, + borderRadius: operationOriginElements?.surfaceBorderRadius ?? "12px", }, { - transform: "translate3d(0, 0, 0) scale(1, 1)", - opacity: 1, + left: `${modalRect.left}px`, + top: `${modalRect.top}px`, + width: `${modalRect.width}px`, + height: `${modalRect.height}px`, + borderRadius: "20px", }, ], { - duration: 480, - easing: "cubic-bezier(0.16, 1, 0.3, 1)", + duration: operationMorphOpenDuration, + easing: operationMorphEasing, fill: "both", } ) @@ -767,14 +952,26 @@ export const Home: React.FC = ({ playState: operationMorphAnimationRef.current.playState, }) operationMorphAnimationRef.current.onfinish = () => { + modalEl.classList.remove("ss-operation-morph-active") + operationElementAnimationsRef.current.forEach((animation) => animation.cancel()) + operationElementAnimationsRef.current = [] logHome("operation:morph:animation-finish") } operationMorphAnimationRef.current.oncancel = () => { + modalEl.classList.remove("ss-operation-morph-active") logHome("operation:morph:animation-cancel") } return true }, - [isPortraitMode, operationOriginRect, operationModalClass, operationModalRootClass] + [ + isPortraitMode, + operationMorphEasing, + operationMorphOpenDuration, + operationOriginElements, + operationOriginRect, + operationModalClass, + operationModalRootClass, + ] ) useLayoutEffect(() => { @@ -808,15 +1005,15 @@ export const Home: React.FC = ({ }, [isPortraitMode, operationVisible, operationOriginRect, playOperationMorph]) const finishCloseOperationModal = useCallback( - (skipCancelMorph = false, skipCancelMask = false) => { + (skipCancelMorph = false) => { if (!skipCancelMorph) { operationMorphAnimationRef.current?.cancel() } operationMorphAnimationRef.current = null - if (!skipCancelMask) { - operationMaskAnimationRef.current?.cancel() - } + operationMaskAnimationRef.current?.cancel() operationMaskAnimationRef.current = null + operationElementAnimationsRef.current.forEach((animation) => animation.cancel()) + operationElementAnimationsRef.current = [] if (operationMorphRafRef.current !== null) { window.cancelAnimationFrame(operationMorphRafRef.current) operationMorphRafRef.current = null @@ -824,6 +1021,7 @@ export const Home: React.FC = ({ operationClosingRef.current = false setOperationVisible(false) setOperationOriginRect(null) + setOperationOriginElements(null) }, [] ) @@ -845,56 +1043,156 @@ export const Home: React.FC = ({ !operationClosingRef.current ) { const modalEl = document.querySelector(`.${operationModalClass}`) as HTMLElement | null - if (modalEl) { - const modalRect = modalEl.getBoundingClientRect() + const modalSurfaceEl = modalEl + ? ((modalEl.querySelector(".ant-modal-container, .ant-modal-content") as HTMLElement | null) ?? + modalEl) + : null + if (modalEl && modalSurfaceEl) { + const modalRect = modalSurfaceEl.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" + operationElementAnimationsRef.current.forEach((animation) => animation.cancel()) + operationElementAnimationsRef.current = [] + modalEl.classList.remove("ss-operation-morph-active") + modalEl.classList.add("ss-operation-morph-closing") + modalSurfaceEl.style.transformOrigin = "top left" + modalSurfaceEl.style.willChange = "left, top, width, height" 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", - }) + const closingElementTargets = [ + { + key: "avatar", + selector: ".ss-operation-designed-avatar", + sourceRect: operationOriginElements?.avatar ?? null, + }, + { + key: "name", + selector: ".ss-operation-designed-name", + sourceRect: operationOriginElements?.name ?? null, + }, + { + key: "score", + selector: ".ss-operation-designed-score-value", + sourceRect: operationOriginElements?.score ?? null, + }, + ] + const previousSurfaceStyle = { + position: modalSurfaceEl.style.position, + left: modalSurfaceEl.style.left, + top: modalSurfaceEl.style.top, + width: modalSurfaceEl.style.width, + height: modalSurfaceEl.style.height, + margin: modalSurfaceEl.style.margin, + transform: modalSurfaceEl.style.transform, } - operationMorphAnimationRef.current = modalEl.animate( + modalSurfaceEl.style.position = "fixed" + modalSurfaceEl.style.left = `${operationOriginRect.left}px` + modalSurfaceEl.style.top = `${operationOriginRect.top}px` + modalSurfaceEl.style.width = `${operationOriginRect.width}px` + modalSurfaceEl.style.height = `${operationOriginRect.height}px` + modalSurfaceEl.style.margin = "0" + modalSurfaceEl.style.transform = "none" + const collapsedElementRects = new Map() + for (const item of closingElementTargets) { + const targetEl = modalEl.querySelector(item.selector) as HTMLElement | null + if (targetEl) collapsedElementRects.set(item.key, targetEl.getBoundingClientRect()) + } + modalSurfaceEl.style.position = previousSurfaceStyle.position + modalSurfaceEl.style.left = previousSurfaceStyle.left + modalSurfaceEl.style.top = previousSurfaceStyle.top + modalSurfaceEl.style.width = previousSurfaceStyle.width + modalSurfaceEl.style.height = previousSurfaceStyle.height + modalSurfaceEl.style.margin = previousSurfaceStyle.margin + modalSurfaceEl.style.transform = previousSurfaceStyle.transform + + for (const item of closingElementTargets) { + const targetEl = modalEl.querySelector(item.selector) as HTMLElement | null + const collapsedRect = collapsedElementRects.get(item.key) + if (!targetEl || !item.sourceRect || !collapsedRect) continue + const morph = getOperationElementMorph(collapsedRect, item.sourceRect) + const fromKeyframe: Keyframe = { + transform: "translate3d(0, 0, 0) scale(1, 1)", + } + const toKeyframe: Keyframe = { transform: morph.transform } + if (item.key === "avatar" && operationOriginElements?.avatarBorderRadius) { + fromKeyframe.borderRadius = "50%" + toKeyframe.borderRadius = getCompensatedBorderRadius( + operationOriginElements.avatarBorderRadius, + morph.scale + ) + } + targetEl.style.transformOrigin = "top left" + operationElementAnimationsRef.current.push( + targetEl.animate([fromKeyframe, toKeyframe], { + duration: operationMorphCloseDuration, + easing: operationMorphCloseEasing, + fill: "both", + }) + ) + } + logHome("operation:morph:close-keyframes", { + sourceRect: operationOriginRect.toJSON(), + targetRect: modalRect.toJSON(), + deltaX: operationOriginRect.left - modalRect.left, + deltaY: operationOriginRect.top - modalRect.top, + duration: operationMorphCloseDuration, + easing: operationMorphCloseEasing, + animation: "真实插值位置与宽高;遮罩同步淡出", + }) + operationMorphAnimationRef.current = modalSurfaceEl.animate( [ - { transform: "translate3d(0, 0, 0) scale(1, 1)", opacity: 1 }, { - transform: `translate3d(${toX}px, ${toY}px, 0) scale(${toScaleX}, ${toScaleY})`, - opacity: 0, + left: `${modalRect.left}px`, + top: `${modalRect.top}px`, + width: `${modalRect.width}px`, + height: `${modalRect.height}px`, + borderRadius: "20px", + }, + { + left: `${operationOriginRect.left}px`, + top: `${operationOriginRect.top}px`, + width: `${operationOriginRect.width}px`, + height: `${operationOriginRect.height}px`, + borderRadius: operationOriginElements?.surfaceBorderRadius ?? "12px", }, ], { - duration: 320, - easing: "cubic-bezier(0.4, 0, 1, 1)", + duration: operationMorphCloseDuration, + easing: operationMorphCloseEasing, fill: "both", } ) + if (maskEl) { + operationMaskAnimationRef.current = maskEl.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: operationMorphCloseDuration, + easing: operationMorphCloseEasing, + fill: "both", + }) + logHome("operation:morph:mask-fade-out", { + duration: operationMorphCloseDuration, + easing: operationMorphCloseEasing, + keyframes: [1, 0], + }) + } 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" + logHome("operation:morph:mask-fade-out-finish") } - finishCloseOperationModal(true, true) + modalSurfaceEl.style.visibility = "hidden" + modalEl.classList.remove("ss-operation-morph-closing") + operationElementAnimationsRef.current.forEach((animation) => animation.cancel()) + operationElementAnimationsRef.current = [] + finishCloseOperationModal(true) } operationMorphAnimationRef.current.oncancel = () => { if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) { @@ -902,6 +1200,7 @@ export const Home: React.FC = ({ return } logHome("operation:morph:close-animation-cancel") + modalEl.classList.remove("ss-operation-morph-closing") finishCloseOperationModal() } return @@ -912,7 +1211,11 @@ export const Home: React.FC = ({ }, [ finishCloseOperationModal, isPortraitMode, + operationMorphCloseDuration, + operationMorphCloseEasing, operationModalClass, + operationModalRootClass, + operationOriginElements, operationOriginRect, operationVisible, ]) @@ -1399,6 +1702,7 @@ export const Home: React.FC = ({ > {student.avatarUrl ? ( {student.name} = ({ /> ) : (
= ({ }} >
= ({ )} 0 ? "success" @@ -2609,17 +2916,19 @@ export const Home: React.FC = ({ const operationTargets = batchMode ? selectedStudents : selectedStudent ? [selectedStudent] : [] const isBatchOperation = operationTargets.length > 1 || (batchMode && operationTargets.length > 0) - const operationPanelContent = operationTargets.length > 0 && ( + const legacyOperationPanelContent = operationTargets.length > 0 && (
= ({ gap: "8px", padding: "12px 16px", backgroundColor: "var(--ss-bg-color)", - borderRadius: "8px", + borderRadius: "14px", }} > -
+
{!isBatchOperation && selectedStudent?.avatarUrl ? ( = ({ {t("home.batchMode")} )} = ({
{!isBatchOperation && selectedStudent && ( -
+
{t("home.currentScore")}: @@ -2699,17 +3015,34 @@ export const Home: React.FC = ({ )}
+ {!isBatchOperation && ( +
+
{t("home.noReasonQuickActions")}
+
+ {[-3, -2, -1, 1, 2, 3, 4, 5].map((num) => ( + + ))} +
+
+ )} +
-
+
= ({
-
+
= ({
-
-
- {t("home.noReasonQuickActions")} -
-
- {[-3, -2, -1, 1, 2, 3, 4, 5].map((num) => ( - - ))} -
-
- {groupedReasons.length > 0 && (
= ({
) + const operationPanelContent = operationTargets.length > 0 && ( +
+
+
+ {!isBatchOperation && selectedStudent?.avatarUrl ? ( + {selectedStudent.name} + ) : !isBatchOperation ? ( +
+ {getDisplayText(selectedStudent?.name || "")} +
+ ) : ( + {t("home.batchMode")} + )} + + {!isBatchOperation + ? selectedStudent?.name + : t("home.selectedCount", { count: operationTargets.length })} + +
+ {!isBatchOperation && selectedStudent && ( +
+ {t("home.currentScore")} + 0 + ? "success" + : selectedStudent.score < 0 + ? "error" + : "default" + } + > + {selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score} + +
+ )} + +
+ +
+ {[-5, -3, -2, -1, 1, 2, 3, 4, 5].map((num) => ( + + ))} +
+ +
+ +
+ {groupedReasons.map(([category, items]) => ( +
+
{category}
+
+ {items.map((r) => ( + + ))} +
+
+ ))} +
+
+ ) + + void legacyOperationPanelContent + const applyDrawerDragRegion = useCallback((open: boolean) => { if (!open || typeof document === "undefined") return requestAnimationFrame(() => { @@ -3781,17 +4182,11 @@ export const Home: React.FC = ({ ) : ( = ({ className={operationModalClass} styles={{ body: { - maxHeight: "calc(100vh - 220px)", + maxHeight: "calc(100vh - 140px)", + padding: "0 28px 34px", overflowY: "auto", }, }}