mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
feat: 优化积分弹窗共享元素过渡动画
This commit is contained in:
+465
-17
@@ -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;
|
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 {
|
.ss-home-operation-morph-modal .ant-modal-content {
|
||||||
transform-origin: center;
|
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,
|
.ss-home-operation-morph-modal .ss-operation-panel-section,
|
||||||
@@ -302,6 +699,29 @@ html.platform-macos body {
|
|||||||
font-weight: 700;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.ss-operation-panel-grid {
|
.ss-operation-panel-grid {
|
||||||
grid-template-columns: 1fr !important;
|
grid-template-columns: 1fr !important;
|
||||||
@@ -315,26 +735,54 @@ html.platform-macos body {
|
|||||||
.ss-reason-preset-buttons .ant-btn {
|
.ss-reason-preset-buttons .ant-btn {
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.ss-operation-mask-fade-enter,
|
.ss-operation-quick-buttons {
|
||||||
.ss-operation-mask-fade-appear {
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
opacity: 0 !important;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.ss-operation-mask-fade-enter-active,
|
.ss-operation-designed-header {
|
||||||
.ss-operation-mask-fade-appear-active {
|
min-height: 84px;
|
||||||
opacity: 1 !important;
|
gap: 8px;
|
||||||
transition: opacity 260ms cubic-bezier(0.2, 0, 0, 1) !important;
|
padding: 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ss-operation-mask-fade-leave {
|
.ss-operation-designed-student img,
|
||||||
opacity: 1 !important;
|
.ss-operation-designed-avatar-fallback {
|
||||||
}
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
flex-basis: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
.ss-operation-mask-fade-leave-active {
|
.ss-operation-designed-student > span {
|
||||||
opacity: 0 !important;
|
font-size: 21px;
|
||||||
transition: opacity 220ms cubic-bezier(0.4, 0, 1, 1) !important;
|
}
|
||||||
|
|
||||||
|
.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 {
|
@keyframes ss-reward-modal-morph-in {
|
||||||
|
|||||||
+499
-103
@@ -64,6 +64,35 @@ interface rewardSetting {
|
|||||||
cost_points: number
|
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 SortType = "alphabet" | "surname" | "group" | "score"
|
||||||
type LayoutType = "grouped" | "squareGrid" | "largeAvatar"
|
type LayoutType = "grouped" | "squareGrid" | "largeAvatar"
|
||||||
type SearchKeyboardLayout = "t9" | "qwerty26"
|
type SearchKeyboardLayout = "t9" | "qwerty26"
|
||||||
@@ -138,6 +167,8 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
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 [operationOriginRect, setOperationOriginRect] = useState<DOMRect | null>(null)
|
||||||
|
const [operationOriginElements, setOperationOriginElements] =
|
||||||
|
useState<operationMorphElementRects | 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)
|
||||||
@@ -163,11 +194,16 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
const fetchRequestIdRef = useRef(0)
|
const fetchRequestIdRef = useRef(0)
|
||||||
const operationMorphAnimationRef = useRef<Animation | null>(null)
|
const operationMorphAnimationRef = useRef<Animation | null>(null)
|
||||||
const operationMaskAnimationRef = useRef<Animation | null>(null)
|
const operationMaskAnimationRef = useRef<Animation | null>(null)
|
||||||
|
const operationElementAnimationsRef = useRef<Animation[]>([])
|
||||||
const operationMorphRafRef = useRef<number | null>(null)
|
const operationMorphRafRef = useRef<number | null>(null)
|
||||||
const operationClosingRef = useRef(false)
|
const operationClosingRef = useRef(false)
|
||||||
const operationCloseTokenRef = useRef(0)
|
const operationCloseTokenRef = useRef(0)
|
||||||
const operationModalRootClass = "ss-home-operation-morph-root"
|
const operationModalRootClass = "ss-home-operation-morph-root"
|
||||||
const operationModalClass = "ss-home-operation-morph-modal"
|
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") => {
|
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 } }))
|
||||||
@@ -176,9 +212,9 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
const logHome = (message: string, meta?: Record<string, unknown>) => {
|
const logHome = (message: string, meta?: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
if (meta) {
|
if (meta) {
|
||||||
console.error(`[Home][Diag] ${message}`, meta)
|
console.info(`[Home][积分弹窗] ${message}`, meta)
|
||||||
} else {
|
} else {
|
||||||
console.error(`[Home][Diag] ${message}`)
|
console.info(`[Home][积分弹窗] ${message}`)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
void 0
|
void 0
|
||||||
@@ -642,11 +678,45 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cardEl = sourceEl?.querySelector(".ant-card") as HTMLElement | null
|
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<HTMLElement>("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", {
|
logHome("operation:morph:open", {
|
||||||
student: student.name,
|
student: student.name,
|
||||||
hasSourceEl: Boolean(sourceEl),
|
hasSourceEl: Boolean(sourceEl),
|
||||||
hasCardEl: Boolean(cardEl),
|
hasCardEl: Boolean(cardEl),
|
||||||
|
hasCardBodyEl: Boolean(cardBodyEl),
|
||||||
sourceRect: sourceRect
|
sourceRect: sourceRect
|
||||||
? {
|
? {
|
||||||
left: sourceRect.left,
|
left: sourceRect.left,
|
||||||
@@ -655,12 +725,22 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
height: sourceRect.height,
|
height: sourceRect.height,
|
||||||
}
|
}
|
||||||
: null,
|
: 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)
|
setOperationOriginRect(sourceRect)
|
||||||
|
setOperationOriginElements(sourceElements)
|
||||||
operationClosingRef.current = false
|
operationClosingRef.current = false
|
||||||
operationCloseTokenRef.current += 1
|
operationCloseTokenRef.current += 1
|
||||||
operationMorphAnimationRef.current?.cancel()
|
operationMorphAnimationRef.current?.cancel()
|
||||||
operationMaskAnimationRef.current?.cancel()
|
operationMaskAnimationRef.current?.cancel()
|
||||||
|
operationElementAnimationsRef.current.forEach((animation) => animation.cancel())
|
||||||
|
operationElementAnimationsRef.current = []
|
||||||
|
|
||||||
setSelectedStudent(student)
|
setSelectedStudent(student)
|
||||||
setCustomScore(undefined)
|
setCustomScore(undefined)
|
||||||
@@ -686,29 +766,54 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
})
|
})
|
||||||
return false
|
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.
|
logHome("operation:morph:surface-found", {
|
||||||
operationMorphAnimationRef.current?.cancel()
|
attempt,
|
||||||
for (const animation of modalEl.getAnimations()) {
|
surfaceTag: modalSurfaceEl.tagName,
|
||||||
animation.cancel()
|
surfaceClassName: modalSurfaceEl.className,
|
||||||
}
|
usesModalContainer: modalSurfaceEl.classList.contains("ant-modal-container"),
|
||||||
modalEl.style.transform = ""
|
usesModalContent: modalSurfaceEl.classList.contains("ant-modal-content"),
|
||||||
modalEl.style.opacity = ""
|
})
|
||||||
modalEl.style.visibility = ""
|
|
||||||
const maskEl = document.querySelector(
|
const maskEl = document.querySelector(
|
||||||
`.${operationModalRootClass} .ant-modal-mask`
|
`.${operationModalRootClass} .ant-modal-mask`
|
||||||
) as HTMLElement | null
|
) as HTMLElement | null
|
||||||
if (maskEl) {
|
if (maskEl) {
|
||||||
operationMaskAnimationRef.current?.cancel()
|
operationMaskAnimationRef.current?.cancel()
|
||||||
maskEl.style.opacity = ""
|
maskEl.style.visibility = "visible"
|
||||||
maskEl.style.visibility = ""
|
|
||||||
operationMaskAnimationRef.current = maskEl.animate([{ opacity: 0 }, { opacity: 1 }], {
|
operationMaskAnimationRef.current = maskEl.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||||
duration: 220,
|
duration: operationMorphOpenDuration,
|
||||||
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
easing: operationMorphEasing,
|
||||||
fill: "both",
|
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) {
|
if (modalRect.width <= 0 || modalRect.height <= 0) {
|
||||||
logHome("operation:morph:modal-rect-invalid", {
|
logHome("operation:morph:modal-rect-invalid", {
|
||||||
attempt,
|
attempt,
|
||||||
@@ -717,11 +822,6 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
})
|
})
|
||||||
return false
|
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", {
|
logHome("operation:morph:computed", {
|
||||||
modalRect: {
|
modalRect: {
|
||||||
left: modalRect.left,
|
left: modalRect.left,
|
||||||
@@ -729,35 +829,120 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
width: modalRect.width,
|
width: modalRect.width,
|
||||||
height: modalRect.height,
|
height: modalRect.height,
|
||||||
},
|
},
|
||||||
fromX,
|
deltaX: operationOriginRect.left - modalRect.left,
|
||||||
fromY,
|
deltaY: operationOriginRect.top - modalRect.top,
|
||||||
scaleX,
|
|
||||||
scaleY,
|
|
||||||
expectedStartRect: {
|
expectedStartRect: {
|
||||||
left: modalRect.left + fromX,
|
left: operationOriginRect.left,
|
||||||
top: modalRect.top + fromY,
|
top: operationOriginRect.top,
|
||||||
width: modalRect.width * scaleX,
|
width: operationOriginRect.width,
|
||||||
height: modalRect.height * scaleY,
|
height: operationOriginRect.height,
|
||||||
},
|
},
|
||||||
animateSupported: typeof modalEl.animate === "function",
|
animateSupported: typeof modalEl.animate === "function",
|
||||||
|
duration: operationMorphOpenDuration,
|
||||||
|
easing: operationMorphEasing,
|
||||||
|
animation: "ant-card-body 与 ant-modal-container 重合后,真实插值位置与宽高;遮罩同步淡入",
|
||||||
})
|
})
|
||||||
|
|
||||||
modalEl.style.transformOrigin = "top left"
|
modalSurfaceEl.style.position = "fixed"
|
||||||
modalEl.style.willChange = "transform, opacity"
|
modalSurfaceEl.style.left = `${operationOriginRect.left}px`
|
||||||
operationMorphAnimationRef.current = modalEl.animate(
|
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 = [
|
||||||
{
|
{
|
||||||
transform: `translate3d(${fromX}px, ${fromY}px, 0) scale(${scaleX}, ${scaleY})`,
|
key: "avatar",
|
||||||
opacity: 0.86,
|
selector: ".ss-operation-designed-avatar",
|
||||||
|
sourceRect: operationOriginElements?.avatar ?? null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
transform: "translate3d(0, 0, 0) scale(1, 1)",
|
key: "name",
|
||||||
opacity: 1,
|
selector: ".ss-operation-designed-name",
|
||||||
|
sourceRect: operationOriginElements?.name ?? null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "score",
|
||||||
|
selector: ".ss-operation-designed-score-value",
|
||||||
|
sourceRect: operationOriginElements?.score ?? null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const elementMorphLog: Record<string, unknown> = {}
|
||||||
|
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(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
left: `${operationOriginRect.left}px`,
|
||||||
|
top: `${operationOriginRect.top}px`,
|
||||||
|
width: `${operationOriginRect.width}px`,
|
||||||
|
height: `${operationOriginRect.height}px`,
|
||||||
|
borderRadius: operationOriginElements?.surfaceBorderRadius ?? "12px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
left: `${modalRect.left}px`,
|
||||||
|
top: `${modalRect.top}px`,
|
||||||
|
width: `${modalRect.width}px`,
|
||||||
|
height: `${modalRect.height}px`,
|
||||||
|
borderRadius: "20px",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
duration: 480,
|
duration: operationMorphOpenDuration,
|
||||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
easing: operationMorphEasing,
|
||||||
fill: "both",
|
fill: "both",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -767,14 +952,26 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
playState: operationMorphAnimationRef.current.playState,
|
playState: operationMorphAnimationRef.current.playState,
|
||||||
})
|
})
|
||||||
operationMorphAnimationRef.current.onfinish = () => {
|
operationMorphAnimationRef.current.onfinish = () => {
|
||||||
|
modalEl.classList.remove("ss-operation-morph-active")
|
||||||
|
operationElementAnimationsRef.current.forEach((animation) => animation.cancel())
|
||||||
|
operationElementAnimationsRef.current = []
|
||||||
logHome("operation:morph:animation-finish")
|
logHome("operation:morph:animation-finish")
|
||||||
}
|
}
|
||||||
operationMorphAnimationRef.current.oncancel = () => {
|
operationMorphAnimationRef.current.oncancel = () => {
|
||||||
|
modalEl.classList.remove("ss-operation-morph-active")
|
||||||
logHome("operation:morph:animation-cancel")
|
logHome("operation:morph:animation-cancel")
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[isPortraitMode, operationOriginRect, operationModalClass, operationModalRootClass]
|
[
|
||||||
|
isPortraitMode,
|
||||||
|
operationMorphEasing,
|
||||||
|
operationMorphOpenDuration,
|
||||||
|
operationOriginElements,
|
||||||
|
operationOriginRect,
|
||||||
|
operationModalClass,
|
||||||
|
operationModalRootClass,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -808,15 +1005,15 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
}, [isPortraitMode, operationVisible, operationOriginRect, playOperationMorph])
|
}, [isPortraitMode, operationVisible, operationOriginRect, playOperationMorph])
|
||||||
|
|
||||||
const finishCloseOperationModal = useCallback(
|
const finishCloseOperationModal = useCallback(
|
||||||
(skipCancelMorph = false, skipCancelMask = false) => {
|
(skipCancelMorph = false) => {
|
||||||
if (!skipCancelMorph) {
|
if (!skipCancelMorph) {
|
||||||
operationMorphAnimationRef.current?.cancel()
|
operationMorphAnimationRef.current?.cancel()
|
||||||
}
|
}
|
||||||
operationMorphAnimationRef.current = null
|
operationMorphAnimationRef.current = null
|
||||||
if (!skipCancelMask) {
|
|
||||||
operationMaskAnimationRef.current?.cancel()
|
operationMaskAnimationRef.current?.cancel()
|
||||||
}
|
|
||||||
operationMaskAnimationRef.current = null
|
operationMaskAnimationRef.current = null
|
||||||
|
operationElementAnimationsRef.current.forEach((animation) => animation.cancel())
|
||||||
|
operationElementAnimationsRef.current = []
|
||||||
if (operationMorphRafRef.current !== null) {
|
if (operationMorphRafRef.current !== null) {
|
||||||
window.cancelAnimationFrame(operationMorphRafRef.current)
|
window.cancelAnimationFrame(operationMorphRafRef.current)
|
||||||
operationMorphRafRef.current = null
|
operationMorphRafRef.current = null
|
||||||
@@ -824,6 +1021,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
operationClosingRef.current = false
|
operationClosingRef.current = false
|
||||||
setOperationVisible(false)
|
setOperationVisible(false)
|
||||||
setOperationOriginRect(null)
|
setOperationOriginRect(null)
|
||||||
|
setOperationOriginElements(null)
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
@@ -845,56 +1043,156 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
!operationClosingRef.current
|
!operationClosingRef.current
|
||||||
) {
|
) {
|
||||||
const modalEl = document.querySelector(`.${operationModalClass}`) as HTMLElement | null
|
const modalEl = document.querySelector(`.${operationModalClass}`) as HTMLElement | null
|
||||||
if (modalEl) {
|
const modalSurfaceEl = modalEl
|
||||||
const modalRect = modalEl.getBoundingClientRect()
|
? ((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) {
|
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
|
const closeToken = ++operationCloseTokenRef.current
|
||||||
operationClosingRef.current = true
|
operationClosingRef.current = true
|
||||||
operationMorphAnimationRef.current?.cancel()
|
operationMorphAnimationRef.current?.cancel()
|
||||||
operationMaskAnimationRef.current?.cancel()
|
operationMaskAnimationRef.current?.cancel()
|
||||||
modalEl.style.transformOrigin = "top left"
|
operationElementAnimationsRef.current.forEach((animation) => animation.cancel())
|
||||||
modalEl.style.willChange = "transform, opacity"
|
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(
|
const maskEl = document.querySelector(
|
||||||
`.${operationModalRootClass} .ant-modal-mask`
|
`.${operationModalRootClass} .ant-modal-mask`
|
||||||
) as HTMLElement | null
|
) as HTMLElement | null
|
||||||
if (maskEl) {
|
const closingElementTargets = [
|
||||||
operationMaskAnimationRef.current = maskEl.animate([{ opacity: 1 }, { opacity: 0 }], {
|
{
|
||||||
duration: 320,
|
key: "avatar",
|
||||||
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
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,
|
||||||
|
}
|
||||||
|
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<string, DOMRect>()
|
||||||
|
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",
|
fill: "both",
|
||||||
})
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
operationMorphAnimationRef.current = modalEl.animate(
|
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})`,
|
left: `${modalRect.left}px`,
|
||||||
opacity: 0,
|
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,
|
duration: operationMorphCloseDuration,
|
||||||
easing: "cubic-bezier(0.4, 0, 1, 1)",
|
easing: operationMorphCloseEasing,
|
||||||
fill: "both",
|
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 = () => {
|
operationMorphAnimationRef.current.onfinish = () => {
|
||||||
if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) {
|
if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) {
|
||||||
logHome("operation:morph:close-animation-finish:stale", { closeToken })
|
logHome("operation:morph:close-animation-finish:stale", { closeToken })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logHome("operation:morph:close-animation-finish")
|
logHome("operation:morph:close-animation-finish")
|
||||||
modalEl.style.opacity = "0"
|
|
||||||
modalEl.style.visibility = "hidden"
|
|
||||||
if (maskEl) {
|
if (maskEl) {
|
||||||
maskEl.style.opacity = "0"
|
|
||||||
maskEl.style.visibility = "hidden"
|
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 = () => {
|
operationMorphAnimationRef.current.oncancel = () => {
|
||||||
if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) {
|
if (operationCloseTokenRef.current !== closeToken || !operationClosingRef.current) {
|
||||||
@@ -902,6 +1200,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
logHome("operation:morph:close-animation-cancel")
|
logHome("operation:morph:close-animation-cancel")
|
||||||
|
modalEl.classList.remove("ss-operation-morph-closing")
|
||||||
finishCloseOperationModal()
|
finishCloseOperationModal()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -912,7 +1211,11 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
}, [
|
}, [
|
||||||
finishCloseOperationModal,
|
finishCloseOperationModal,
|
||||||
isPortraitMode,
|
isPortraitMode,
|
||||||
|
operationMorphCloseDuration,
|
||||||
|
operationMorphCloseEasing,
|
||||||
operationModalClass,
|
operationModalClass,
|
||||||
|
operationModalRootClass,
|
||||||
|
operationOriginElements,
|
||||||
operationOriginRect,
|
operationOriginRect,
|
||||||
operationVisible,
|
operationVisible,
|
||||||
])
|
])
|
||||||
@@ -1399,6 +1702,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
>
|
>
|
||||||
{student.avatarUrl ? (
|
{student.avatarUrl ? (
|
||||||
<img
|
<img
|
||||||
|
data-operation-morph="avatar"
|
||||||
src={student.avatarUrl}
|
src={student.avatarUrl}
|
||||||
alt={student.name}
|
alt={student.name}
|
||||||
style={{
|
style={{
|
||||||
@@ -1414,6 +1718,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
data-operation-morph="avatar"
|
||||||
style={{
|
style={{
|
||||||
width: isPortraitMode ? "40px" : "44px",
|
width: isPortraitMode ? "40px" : "44px",
|
||||||
height: isPortraitMode ? "40px" : "44px",
|
height: isPortraitMode ? "40px" : "44px",
|
||||||
@@ -1503,6 +1808,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
data-operation-morph="name"
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: "15px",
|
fontSize: "15px",
|
||||||
@@ -1523,6 +1829,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
<Tag
|
<Tag
|
||||||
|
data-operation-morph="score"
|
||||||
color={
|
color={
|
||||||
getDisplayPoints(student) > 0
|
getDisplayPoints(student) > 0
|
||||||
? "success"
|
? "success"
|
||||||
@@ -2609,17 +2916,19 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
const operationTargets = batchMode ? selectedStudents : selectedStudent ? [selectedStudent] : []
|
const operationTargets = batchMode ? selectedStudents : selectedStudent ? [selectedStudent] : []
|
||||||
const isBatchOperation = operationTargets.length > 1 || (batchMode && operationTargets.length > 0)
|
const isBatchOperation = operationTargets.length > 1 || (batchMode && operationTargets.length > 0)
|
||||||
|
|
||||||
const operationPanelContent = operationTargets.length > 0 && (
|
const legacyOperationPanelContent = operationTargets.length > 0 && (
|
||||||
<div
|
<div
|
||||||
|
className="ss-operation-panel"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "20px",
|
gap: "16px",
|
||||||
padding: "8px 0",
|
padding: "4px 0 0",
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
className="ss-operation-panel-header"
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -2628,10 +2937,13 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
gap: "8px",
|
gap: "8px",
|
||||||
padding: "12px 16px",
|
padding: "12px 16px",
|
||||||
backgroundColor: "var(--ss-bg-color)",
|
backgroundColor: "var(--ss-bg-color)",
|
||||||
borderRadius: "8px",
|
borderRadius: "14px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px", minWidth: 0, flex: 1 }}>
|
<div
|
||||||
|
className="ss-operation-panel-student"
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "12px", minWidth: 0, flex: 1 }}
|
||||||
|
>
|
||||||
{!isBatchOperation && selectedStudent?.avatarUrl ? (
|
{!isBatchOperation && selectedStudent?.avatarUrl ? (
|
||||||
<img
|
<img
|
||||||
src={selectedStudent.avatarUrl}
|
src={selectedStudent.avatarUrl}
|
||||||
@@ -2665,6 +2977,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
<Tag color="processing">{t("home.batchMode")}</Tag>
|
<Tag color="processing">{t("home.batchMode")}</Tag>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
|
className="ss-operation-panel-student-name"
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -2679,7 +2992,10 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!isBatchOperation && selectedStudent && (
|
{!isBatchOperation && selectedStudent && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexShrink: 0 }}>
|
<div
|
||||||
|
className="ss-operation-panel-score"
|
||||||
|
style={{ display: "flex", alignItems: "center", gap: "8px", flexShrink: 0 }}
|
||||||
|
>
|
||||||
<span style={{ color: "var(--ss-text-secondary)", fontSize: "13px" }}>
|
<span style={{ color: "var(--ss-text-secondary)", fontSize: "13px" }}>
|
||||||
{t("home.currentScore")}:
|
{t("home.currentScore")}:
|
||||||
</span>
|
</span>
|
||||||
@@ -2699,17 +3015,34 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isBatchOperation && (
|
||||||
|
<div className="ss-operation-quick-strip">
|
||||||
|
<div className="ss-operation-quick-strip-label">{t("home.noReasonQuickActions")}</div>
|
||||||
|
<div className="ss-no-reason-quick-buttons ss-operation-quick-buttons">
|
||||||
|
{[-3, -2, -1, 1, 2, 3, 4, 5].map((num) => (
|
||||||
|
<Button
|
||||||
|
key={num}
|
||||||
|
danger={num < 0}
|
||||||
|
onClick={() => handleNoReasonQuickSelect(num)}
|
||||||
|
>
|
||||||
|
{num > 0 ? `+${num}` : num}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="ss-operation-panel-grid"
|
className="ss-operation-panel-grid"
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: isPortraitMode ? "1fr" : "minmax(0, 0.95fr) minmax(0, 1.25fr)",
|
gridTemplateColumns: isPortraitMode ? "1fr" : "minmax(0, 0.95fr) minmax(0, 1.25fr)",
|
||||||
gap: "20px",
|
gap: "16px",
|
||||||
alignItems: "start",
|
alignItems: "start",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="ss-operation-panel-section">
|
<div className="ss-operation-panel-section">
|
||||||
<div style={{ marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }}>
|
<div className="ss-operation-section-title" style={{ marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -2852,7 +3185,7 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ss-operation-panel-section">
|
<div className="ss-operation-panel-section">
|
||||||
<div style={{ marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }}>
|
<div className="ss-operation-section-title" style={{ marginBottom: "12px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -2868,25 +3201,6 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
color: "var(--ss-text-secondary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
marginBottom: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("home.noReasonQuickActions")}
|
|
||||||
</div>
|
|
||||||
<div className="ss-no-reason-quick-buttons">
|
|
||||||
{[-3, -2, -1, 1, 2, 3, 4, 5].map((num) => (
|
|
||||||
<Button key={num} danger={num < 0} onClick={() => handleNoReasonQuickSelect(num)}>
|
|
||||||
{num > 0 ? `+${num}` : num}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{groupedReasons.length > 0 && (
|
{groupedReasons.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -2965,6 +3279,93 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const operationPanelContent = operationTargets.length > 0 && (
|
||||||
|
<div className="ss-operation-panel ss-operation-panel-designed">
|
||||||
|
<div className="ss-operation-designed-header">
|
||||||
|
<div className="ss-operation-designed-student">
|
||||||
|
{!isBatchOperation && selectedStudent?.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
className="ss-operation-designed-avatar"
|
||||||
|
src={selectedStudent.avatarUrl}
|
||||||
|
alt={selectedStudent.name}
|
||||||
|
/>
|
||||||
|
) : !isBatchOperation ? (
|
||||||
|
<div
|
||||||
|
className="ss-operation-designed-avatar ss-operation-designed-avatar-fallback"
|
||||||
|
style={{ backgroundColor: getAvatarColor(selectedStudent?.name || "") }}
|
||||||
|
>
|
||||||
|
{getDisplayText(selectedStudent?.name || "")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tag color="processing">{t("home.batchMode")}</Tag>
|
||||||
|
)}
|
||||||
|
<span className="ss-operation-designed-name">
|
||||||
|
{!isBatchOperation
|
||||||
|
? selectedStudent?.name
|
||||||
|
: t("home.selectedCount", { count: operationTargets.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isBatchOperation && selectedStudent && (
|
||||||
|
<div className="ss-operation-designed-score">
|
||||||
|
<span className="ss-operation-designed-score-label">{t("home.currentScore")}</span>
|
||||||
|
<Tag
|
||||||
|
className="ss-operation-designed-score-value"
|
||||||
|
color={
|
||||||
|
selectedStudent.score > 0
|
||||||
|
? "success"
|
||||||
|
: selectedStudent.score < 0
|
||||||
|
? "error"
|
||||||
|
: "default"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedStudent.score > 0 ? `+${selectedStudent.score}` : selectedStudent.score}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
className="ss-operation-designed-close"
|
||||||
|
aria-label={t("common.cancel")}
|
||||||
|
onClick={closeOperationModal}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ss-operation-designed-quick-buttons">
|
||||||
|
{[-5, -3, -2, -1, 1, 2, 3, 4, 5].map((num) => (
|
||||||
|
<Button key={num} onClick={() => handleNoReasonQuickSelect(num)}>
|
||||||
|
{num > 0 ? `+${num}` : num}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ss-operation-designed-divider" />
|
||||||
|
|
||||||
|
<div className="ss-operation-designed-reasons">
|
||||||
|
{groupedReasons.map(([category, items]) => (
|
||||||
|
<section className="ss-operation-designed-category" key={category}>
|
||||||
|
<div className="ss-operation-designed-category-title">{category}</div>
|
||||||
|
<div className="ss-operation-designed-reason-buttons">
|
||||||
|
{items.map((r) => (
|
||||||
|
<Button
|
||||||
|
key={r.id}
|
||||||
|
className={r.delta >= 0 ? "is-positive" : "is-negative"}
|
||||||
|
onClick={() => handleReasonSelect(r)}
|
||||||
|
>
|
||||||
|
<span>{r.content}</span>
|
||||||
|
<strong>{r.delta > 0 ? `+${r.delta}` : r.delta}</strong>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
void legacyOperationPanelContent
|
||||||
|
|
||||||
const applyDrawerDragRegion = useCallback((open: boolean) => {
|
const applyDrawerDragRegion = useCallback((open: boolean) => {
|
||||||
if (!open || typeof document === "undefined") return
|
if (!open || typeof document === "undefined") return
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -3781,17 +4182,11 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
</Drawer>
|
</Drawer>
|
||||||
) : (
|
) : (
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={null}
|
||||||
isBatchOperation
|
|
||||||
? t("home.operationTitleBatch", { count: operationTargets.length })
|
|
||||||
: t("home.operationTitle", { name: selectedStudent?.name })
|
|
||||||
}
|
|
||||||
open={operationVisible}
|
open={operationVisible}
|
||||||
onCancel={closeOperationModal}
|
onCancel={closeOperationModal}
|
||||||
onOk={handleSubmit}
|
closable={false}
|
||||||
confirmLoading={submitLoading}
|
footer={null}
|
||||||
okText={t("home.submitOperation")}
|
|
||||||
cancelText={t("common.cancel")}
|
|
||||||
width={820}
|
width={820}
|
||||||
centered
|
centered
|
||||||
forceRender
|
forceRender
|
||||||
@@ -3801,7 +4196,8 @@ export const Home: React.FC<HomeProps> = ({
|
|||||||
className={operationModalClass}
|
className={operationModalClass}
|
||||||
styles={{
|
styles={{
|
||||||
body: {
|
body: {
|
||||||
maxHeight: "calc(100vh - 220px)",
|
maxHeight: "calc(100vh - 140px)",
|
||||||
|
padding: "0 28px 34px",
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user