feat: 审批卡改为抽屉式——输入框上方滑出,一次一张,radio 保留

This commit is contained in:
Rick Ye
2026-09-08 17:33:37 +08:00
parent e7da0f912e
commit 24df36a9b4
2 changed files with 87 additions and 72 deletions
@@ -90,17 +90,23 @@ function Ico({ path, size = 14, sw = 2 }: { path: ReactNode; size?: number; sw?:
const OPTION_APPROVE = 0
export type ApprovalOutcome = 'approved' | 'rejected'
export function ApprovalCard({
request,
outcome,
onResolve
}: {
request: ApprovalRequest
/** 外部已结算(卡片重挂后恢复结果态):给定时直接呈现结果胶囊,交互冻结 */
outcome?: ApprovalOutcome
onResolve: (id: string, approved: boolean) => void
}): ReactNode {
const [picked, setPicked] = useState<number | null>(null)
const [sent, setSent] = useState<string | null>(null)
const submitTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const settled = useRef(false)
const settledNote = sent ?? (outcome === 'approved' ? '已允许执行' : outcome === 'rejected' ? '已拒绝' : null)
useEffect(
() => () => {
@@ -110,7 +116,7 @@ export function ApprovalCard({
)
const settle = (approved: boolean, note: string): void => {
if (settled.current) return
if (settledNote) return
settled.current = true
setSent(note)
if (submitTimer.current) clearTimeout(submitTimer.current)
@@ -118,7 +124,7 @@ export function ApprovalCard({
}
const pick = (index: number): void => {
if (sent) return
if (settledNote) return
setPicked(index)
// radio 自动前进:480ms 后提交(官方 single-choice 行为)
if (submitTimer.current) clearTimeout(submitTimer.current)
@@ -127,8 +133,8 @@ export function ApprovalCard({
}, 480)
}
if (sent) {
const ok = sent === '已允许执行'
if (settledNote) {
const ok = settledNote === '已允许执行'
return (
<div className="flex w-full items-center gap-3" style={{ animation: 'pop-in 260ms cubic-bezier(0.23,1,0.32,1) both' }}>
<span
@@ -147,7 +153,7 @@ export function ApprovalCard({
</svg>
)}
</span>
{sent}
{settledNote}
</span>
</div>
)
@@ -1,41 +1,18 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
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)'
const FADE_TRANSITION = 'opacity 220ms ease, transform 400ms cubic-bezier(0.22, 1, 0.36, 1)'
type Phase = 'pending' | 'settled' | 'closing' | 'hidden'
/** 跟踪从 requests 里消失的审批:缓存原件让卡片以结算态多停留一拍 */
function useLeavingApprovals(requests: ApprovalRequest[]): ApprovalRequest[] {
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
}
const SETTLED_MS = 900
const CLOSE_MS = 240
export function BottomSlot({
approvals,
@@ -46,48 +23,80 @@ export function BottomSlot({
onResolve: (id: string, approved: boolean) => void
composer: ReactNode
}): ReactNode {
const leaving = useLeavingApprovals(approvals)
const showApproval = approvals.length > 0 || leaving.length > 0
const [shown, setShown] = useState<ApprovalRequest | null>(null)
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 (
<div>
{/* Chatbox 行:审批升起时收起(高度 0 + 下沉淡出) */}
<div style={{ display: 'grid', gridTemplateRows: showApproval ? '0fr' : '1fr', transition: ROWS_TRANSITION }}>
<div className="min-h-0 overflow-hidden">
<div className="relative">
{/* 审批抽屉:悬浮在输入框上方(版面中部),从输入框后缘滑出 */}
<div
aria-hidden={!visible}
className={`pointer-events-none absolute inset-x-0 bottom-full z-20 mb-2 flex justify-center ${visible ? '' : 'invisible'}`}
>
{shown && (
<div
style={{
opacity: showApproval ? 0 : 1,
transform: showApproval ? 'translateY(10px)' : 'none',
transition: FADE_TRANSITION,
pointerEvents: showApproval ? 'none' : undefined
}}
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
}
>
{composer}
<ApprovalCard request={shown} outcome={outcomesRef.current.get(shown.id)} onResolve={handleResolve} />
</div>
</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>
{composer}
<style>{`@keyframes drawer-up { from { opacity: 0; transform: translateY(26px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
</div>
)
}