feat: 审批卡改为抽屉式——输入框上方滑出,一次一张,radio 保留
This commit is contained in:
@@ -90,17 +90,23 @@ function Ico({ path, size = 14, sw = 2 }: { path: ReactNode; size?: number; sw?:
|
|||||||
|
|
||||||
const OPTION_APPROVE = 0
|
const OPTION_APPROVE = 0
|
||||||
|
|
||||||
|
export type ApprovalOutcome = 'approved' | 'rejected'
|
||||||
|
|
||||||
export function ApprovalCard({
|
export function ApprovalCard({
|
||||||
request,
|
request,
|
||||||
|
outcome,
|
||||||
onResolve
|
onResolve
|
||||||
}: {
|
}: {
|
||||||
request: ApprovalRequest
|
request: ApprovalRequest
|
||||||
|
/** 外部已结算(卡片重挂后恢复结果态):给定时直接呈现结果胶囊,交互冻结 */
|
||||||
|
outcome?: ApprovalOutcome
|
||||||
onResolve: (id: string, approved: boolean) => void
|
onResolve: (id: string, approved: boolean) => void
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const [picked, setPicked] = useState<number | null>(null)
|
const [picked, setPicked] = useState<number | null>(null)
|
||||||
const [sent, setSent] = useState<string | null>(null)
|
const [sent, setSent] = useState<string | null>(null)
|
||||||
const submitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const submitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const settled = useRef(false)
|
const settled = useRef(false)
|
||||||
|
const settledNote = sent ?? (outcome === 'approved' ? '已允许执行' : outcome === 'rejected' ? '已拒绝' : null)
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -110,7 +116,7 @@ export function ApprovalCard({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const settle = (approved: boolean, note: string): void => {
|
const settle = (approved: boolean, note: string): void => {
|
||||||
if (settled.current) return
|
if (settledNote) return
|
||||||
settled.current = true
|
settled.current = true
|
||||||
setSent(note)
|
setSent(note)
|
||||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||||
@@ -118,7 +124,7 @@ export function ApprovalCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pick = (index: number): void => {
|
const pick = (index: number): void => {
|
||||||
if (sent) return
|
if (settledNote) return
|
||||||
setPicked(index)
|
setPicked(index)
|
||||||
// radio 自动前进:480ms 后提交(官方 single-choice 行为)
|
// radio 自动前进:480ms 后提交(官方 single-choice 行为)
|
||||||
if (submitTimer.current) clearTimeout(submitTimer.current)
|
if (submitTimer.current) clearTimeout(submitTimer.current)
|
||||||
@@ -127,8 +133,8 @@ export function ApprovalCard({
|
|||||||
}, 480)
|
}, 480)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sent) {
|
if (settledNote) {
|
||||||
const ok = sent === '已允许执行'
|
const ok = settledNote === '已允许执行'
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center gap-3" style={{ animation: 'pop-in 260ms cubic-bezier(0.23,1,0.32,1) both' }}>
|
<div className="flex w-full items-center gap-3" style={{ animation: 'pop-in 260ms cubic-bezier(0.23,1,0.32,1) both' }}>
|
||||||
<span
|
<span
|
||||||
@@ -147,7 +153,7 @@ export function ApprovalCard({
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{sent}
|
{settledNote}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import type { ApprovalRequest } from '@shared/types'
|
import type { ApprovalRequest } from '@shared/types'
|
||||||
import { ApprovalCard } from './ApprovalCard'
|
import { ApprovalCard, type ApprovalOutcome } from './ApprovalCard'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 底部插槽(抽屉):审批卡出现时从 Chatbox 位置升起替换输入框,
|
* 底部插槽(审批抽屉):审批请求出现时,卡片从输入框上方(版面中部)像抽屉
|
||||||
* 结算后输入框滑回。两行用 grid-template-rows 0fr↔1fr 过渡做真实高度动画,
|
* 一样滑出,输入框保留可见可输入(Composer 始终挂载,草稿不丢)。
|
||||||
* 交叉淡入淡出;Composer 始终挂载(草稿不丢)。已结算的卡片以结算胶囊态
|
* 一次只展示一张:队列中的后续请求在前一张结算后接入;结算后卡片以结果
|
||||||
* 多停留约一秒再撤出,避免「啪」一下消失。
|
* 胶囊态停留约一秒,再下滑淡出收起。升起/收起均为 CSS 过渡。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ROWS_TRANSITION = 'grid-template-rows 400ms cubic-bezier(0.22, 1, 0.36, 1)'
|
type Phase = 'pending' | 'settled' | 'closing' | 'hidden'
|
||||||
const FADE_TRANSITION = 'opacity 220ms ease, transform 400ms cubic-bezier(0.22, 1, 0.36, 1)'
|
|
||||||
|
|
||||||
/** 跟踪从 requests 里消失的审批:缓存原件让卡片以结算态多停留一拍 */
|
const SETTLED_MS = 900
|
||||||
function useLeavingApprovals(requests: ApprovalRequest[]): ApprovalRequest[] {
|
const CLOSE_MS = 240
|
||||||
const seenRef = useRef(new Map<string, ApprovalRequest>())
|
|
||||||
for (const request of requests) seenRef.current.set(request.id, request)
|
|
||||||
const [leaving, setLeaving] = useState<ApprovalRequest[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const gone: ApprovalRequest[] = []
|
|
||||||
for (const [id, request] of seenRef.current) {
|
|
||||||
if (!requests.some((r) => r.id === id)) gone.push(request)
|
|
||||||
}
|
|
||||||
if (gone.length === 0) return
|
|
||||||
for (const g of gone) seenRef.current.delete(g.id)
|
|
||||||
setLeaving((cur) => [...cur.filter((l) => !gone.some((g) => g.id === l.id)), ...gone])
|
|
||||||
const timers = gone.map((g) =>
|
|
||||||
setTimeout(() => {
|
|
||||||
setLeaving((cur) => cur.filter((l) => l.id !== g.id))
|
|
||||||
}, 1000)
|
|
||||||
)
|
|
||||||
return () => timers.forEach(clearTimeout)
|
|
||||||
}, [requests])
|
|
||||||
|
|
||||||
return leaving
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BottomSlot({
|
export function BottomSlot({
|
||||||
approvals,
|
approvals,
|
||||||
@@ -46,48 +23,80 @@ export function BottomSlot({
|
|||||||
onResolve: (id: string, approved: boolean) => void
|
onResolve: (id: string, approved: boolean) => void
|
||||||
composer: ReactNode
|
composer: ReactNode
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const leaving = useLeavingApprovals(approvals)
|
const [shown, setShown] = useState<ApprovalRequest | null>(null)
|
||||||
const showApproval = approvals.length > 0 || leaving.length > 0
|
const [phase, setPhase] = useState<Phase>('hidden')
|
||||||
|
const outcomesRef = useRef(new Map<string, ApprovalOutcome>())
|
||||||
|
const timerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const first = approvals[0]
|
||||||
|
if (first) {
|
||||||
|
// 新请求接入(含上一张结算中的队列换牌):立即切换为待决卡片
|
||||||
|
if (timerRef.current) {
|
||||||
|
window.clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
setShown((cur) => (cur?.id === first.id ? cur : first))
|
||||||
|
setPhase('pending')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (phase === 'pending' && shown) {
|
||||||
|
// 待决卡片刚结算且没有后续请求:胶囊态停留一拍,再下滑淡出收起
|
||||||
|
setPhase('settled')
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setPhase('closing')
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
timerRef.current = null
|
||||||
|
setShown(null)
|
||||||
|
setPhase('hidden')
|
||||||
|
}, CLOSE_MS)
|
||||||
|
}, SETTLED_MS)
|
||||||
|
}
|
||||||
|
}, [approvals, phase, shown])
|
||||||
|
|
||||||
|
const handleResolve = (id: string, approved: boolean): void => {
|
||||||
|
outcomesRef.current.set(id, approved ? 'approved' : 'rejected')
|
||||||
|
onResolve(id, approved)
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = shown !== null && phase !== 'hidden'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="relative">
|
||||||
{/* Chatbox 行:审批升起时收起(高度 0 + 下沉淡出) */}
|
{/* 审批抽屉:悬浮在输入框上方(版面中部),从输入框后缘滑出 */}
|
||||||
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '0fr' : '1fr', transition: ROWS_TRANSITION }}>
|
|
||||||
<div className="min-h-0 overflow-hidden">
|
|
||||||
<div
|
<div
|
||||||
style={{
|
aria-hidden={!visible}
|
||||||
opacity: showApproval ? 0 : 1,
|
className={`pointer-events-none absolute inset-x-0 bottom-full z-20 mb-2 flex justify-center ${visible ? '' : 'invisible'}`}
|
||||||
transform: showApproval ? 'translateY(10px)' : 'none',
|
|
||||||
transition: FADE_TRANSITION,
|
|
||||||
pointerEvents: showApproval ? 'none' : undefined
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{shown && (
|
||||||
|
<div
|
||||||
|
key={shown.id}
|
||||||
|
className="pointer-events-auto w-[380px] max-w-[calc(100vw-48px)]"
|
||||||
|
style={
|
||||||
|
phase === 'pending'
|
||||||
|
? { animation: 'drawer-up 340ms cubic-bezier(0.22, 1, 0.36, 1) both' }
|
||||||
|
: phase === 'closing'
|
||||||
|
? {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateY(10px)',
|
||||||
|
transition: 'opacity 200ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1)'
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ApprovalCard request={shown} outcome={outcomesRef.current.get(shown.id)} onResolve={handleResolve} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{composer}
|
{composer}
|
||||||
</div>
|
<style>{`@keyframes drawer-up { from { opacity: 0; transform: translateY(26px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 审批抽屉行:从输入框位置升起(高度 1fr + 上浮淡入) */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '1fr' : '0fr', transition: ROWS_TRANSITION }}>
|
|
||||||
<div className="min-h-0 overflow-hidden">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: showApproval ? 1 : 0,
|
|
||||||
transform: showApproval ? 'none' : 'translateY(14px)',
|
|
||||||
transition: FADE_TRANSITION,
|
|
||||||
pointerEvents: showApproval ? undefined : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-2 pb-1">
|
|
||||||
{[...approvals, ...leaving.filter((l) => !approvals.some((a) => a.id === l.id))].map((request) => (
|
|
||||||
<div key={request.id} className="w-full max-w-80" style={{ animation: 'fade-up 380ms cubic-bezier(0.23,1,0.32,1) both' }}>
|
|
||||||
<ApprovalCard request={request} onResolve={onResolve} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user