refactor: remove demo timers and client-side timeout simulation

Remove lib/config/demo-timers.ts and all usages across stores
and pages. Order timeout scheduling, dispute auto-progression,
and hardcoded countdown displays are removed — timeouts are
now handled server-side by the backend.
This commit is contained in:
zetaloop
2026-05-01 04:10:03 +08:00
parent 0a1a4c877b
commit 452004b194
5 changed files with 58 additions and 213 deletions
+57 -31
View File
@@ -7,14 +7,13 @@ import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { StatusBadge, type StatusBadgeProps } from "@/components/ui/status-badge" import { StatusBadge, type StatusBadgeProps } from "@/components/ui/status-badge"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { getOrderById } from "@/lib/api" import { getOrderById, getPlayerById, uploadFile } from "@/lib/api"
import { import {
getDisputeByOrderId, getDisputeByOrderId,
submitDispute, submitDispute,
submitDisputeAppeal, submitDisputeAppeal,
submitDisputeResponse, submitDisputeResponse,
} from "@/lib/api/disputes" } from "@/lib/api/disputes"
import { DISPUTE_TO_RESOLVED_MS } from "@/lib/config/demo-timers"
import { notifyInfo } from "@/lib/toast" import { notifyInfo } from "@/lib/toast"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-react" import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-react"
@@ -81,12 +80,14 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
const [existingDispute, setExistingDispute] = useState<Awaited< const [existingDispute, setExistingDispute] = useState<Awaited<
ReturnType<typeof getDisputeByOrderId> ReturnType<typeof getDisputeByOrderId>
> | null>(null) > | null>(null)
const [playerUserId, setPlayerUserId] = useState<string | null>(null)
const [reason, setReason] = useState("") const [reason, setReason] = useState("")
const [files, setFiles] = useState<string[]>([]) const [files, setFiles] = useState<string[]>([])
const [responseReason, setResponseReason] = useState("") const [responseReason, setResponseReason] = useState("")
const [responseFiles, setResponseFiles] = useState<string[]>([]) const [responseFiles, setResponseFiles] = useState<string[]>([])
const [appealReason, setAppealReason] = useState("") const [appealReason, setAppealReason] = useState("")
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const responseFileInputRef = useRef<HTMLInputElement>(null) const responseFileInputRef = useRef<HTMLInputElement>(null)
@@ -100,6 +101,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
setLoading(true) setLoading(true)
setOrder(null) setOrder(null)
setExistingDispute(null) setExistingDispute(null)
setPlayerUserId(null)
} }
const load = async () => { const load = async () => {
@@ -113,6 +115,13 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
if (cancelled) return if (cancelled) return
setOrder(nextOrder ?? null) setOrder(nextOrder ?? null)
setExistingDispute(nextDispute ?? null) setExistingDispute(nextDispute ?? null)
if (nextOrder) {
const player = await getPlayerById(String(nextOrder.playerId))
if (cancelled) return
setPlayerUserId(player?.user.id ?? null)
}
setLoading(false) setLoading(false)
} }
@@ -133,34 +142,36 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
useEffect( useEffect(
() => () => { () => () => {
filesRef.current.forEach((url) => { filesRef.current = []
URL.revokeObjectURL(url) responseFilesRef.current = []
})
responseFilesRef.current.forEach((url) => {
URL.revokeObjectURL(url)
})
}, },
[], [],
) )
const handleFileSelect = ( const handleFileSelect = async (
event: ChangeEvent<HTMLInputElement>, event: ChangeEvent<HTMLInputElement>,
setter: Dispatch<SetStateAction<string[]>>, setter: Dispatch<SetStateAction<string[]>>,
currentFiles: string[],
) => { ) => {
const selectedFiles = event.target.files const selectedFiles = event.target.files
if (!selectedFiles?.length) return if (!selectedFiles?.length) return
setter((prev) => { setUploading(true)
const remaining = 5 - prev.length try {
if (remaining <= 0) return prev const remaining = 5 - currentFiles.length
if (remaining <= 0) return
const nextUrls = Array.from(selectedFiles) const nextUrls = await Promise.all(
.slice(0, remaining) Array.from(selectedFiles)
.map((file) => URL.createObjectURL(file)) .slice(0, remaining)
return [...prev, ...nextUrls] .map((file) => uploadFile(file, "dispute")),
}) )
setter((prev) => [...prev, ...nextUrls])
event.target.value = "" } catch {
notifyInfo("证据上传失败")
} finally {
setUploading(false)
event.target.value = ""
}
} }
const removeFile = ( const removeFile = (
@@ -168,13 +179,14 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
filesState: string[], filesState: string[],
setter: Dispatch<SetStateAction<string[]>>, setter: Dispatch<SetStateAction<string[]>>,
) => { ) => {
const removed = filesState[index]
if (removed) {
URL.revokeObjectURL(removed)
}
setter((prev) => prev.filter((_, currentIndex) => currentIndex !== index)) setter((prev) => prev.filter((_, currentIndex) => currentIndex !== index))
} }
const reloadDispute = async () => {
const nextDispute = await getDisputeByOrderId(id)
setExistingDispute(nextDispute ?? null)
}
const handleSubmit = () => { const handleSubmit = () => {
if (!userId || !reason.trim()) return if (!userId || !reason.trim()) return
@@ -212,7 +224,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
} }
const isParticipant = Boolean( const isParticipant = Boolean(
userId && (order.consumerId === userId || order.playerId === userId), userId && (String(order.consumerId) === userId || playerUserId === userId),
) )
if (!isParticipant) { if (!isParticipant) {
return ( return (
@@ -337,12 +349,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
accept="image/*" accept="image/*"
multiple multiple
className="hidden" className="hidden"
onChange={(event) => handleFileSelect(event, setResponseFiles)} onChange={(event) => {
void handleFileSelect(event, setResponseFiles, responseFiles)
}}
/> />
<Button <Button
variant="outline" variant="outline"
className="border-border/60" className="border-border/60"
onClick={() => responseFileInputRef.current?.click()} onClick={() => responseFileInputRef.current?.click()}
disabled={uploading}
> >
<Upload className="mr-1 h-4 w-4" /> <Upload className="mr-1 h-4 w-4" />
@@ -385,7 +400,11 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
).then((decision) => { ).then((decision) => {
if (!decision.ok) { if (!decision.ok) {
notifyInfo(decision.error.msg) notifyInfo(decision.error.msg)
return
} }
setResponseReason("")
setResponseFiles([])
return reloadDispute()
}) })
}} }}
disabled={!responseReason.trim()} disabled={!responseReason.trim()}
@@ -437,7 +456,10 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
).then((decision) => { ).then((decision) => {
if (!decision.ok) { if (!decision.ok) {
notifyInfo(decision.error.msg) notifyInfo(decision.error.msg)
return
} }
setAppealReason("")
return reloadDispute()
}) })
}} }}
disabled={!appealReason.trim()} disabled={!appealReason.trim()}
@@ -477,7 +499,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
<div className="container mx-auto max-w-2xl px-4 py-8"> <div className="container mx-auto max-w-2xl px-4 py-8">
<EmptyState <EmptyState
title="争议已提交,请等待平台处理" title="争议已提交,请等待平台处理"
description={`平台将在约 ${Math.floor(DISPUTE_TO_RESOLVED_MS / 1000)} 秒内给出模拟处理结果。`} description="争议已提交,请等待平台处理"
icon={AlertTriangle} icon={AlertTriangle}
action={ action={
<Button variant="outline" className="border-border/60" asChild> <Button variant="outline" className="border-border/60" asChild>
@@ -527,13 +549,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
accept="image/*" accept="image/*"
multiple multiple
className="hidden" className="hidden"
onChange={(event) => handleFileSelect(event, setFiles)} onChange={(event) => {
void handleFileSelect(event, setFiles, files)
}}
/> />
<button <button
type="button" type="button"
className="w-full rounded-md border-2 border-dashed border-border/60 bg-muted/20 p-6 text-center text-sm text-muted-foreground transition-colors hover:border-primary/60 hover:bg-muted/30 hover:text-foreground disabled:opacity-50" className="w-full rounded-md border-2 border-dashed border-border/60 bg-muted/20 p-6 text-center text-sm text-muted-foreground transition-colors hover:border-primary/60 hover:bg-muted/30 hover:text-foreground disabled:opacity-50"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={files.length >= 5} disabled={files.length >= 5 || uploading}
> >
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Upload className="h-5 w-5" /> <Upload className="h-5 w-5" />
@@ -566,13 +590,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
))} ))}
</div> </div>
)} )}
<div className="text-xs text-muted-foreground">5</div> <div className="text-xs text-muted-foreground">
{uploading ? "证据上传中..." : "最多5张"}
</div>
</div> </div>
<div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground"> <div className="space-y-1 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
<p>· </p> <p>· </p>
<p>· </p> <p>· </p>
<p>· {Math.floor(DISPUTE_TO_RESOLVED_MS / 1000)} </p> <p>· </p>
<p>· </p> <p>· </p>
</div> </div>
-34
View File
@@ -6,7 +6,6 @@ import { EmptyState } from "@/components/ui/empty-state"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { StatusBadge } from "@/components/ui/status-badge" import { StatusBadge } from "@/components/ui/status-badge"
import { getOrderById, listChatSessions, listReviewsByOrder } from "@/lib/api" import { getOrderById, listChatSessions, listReviewsByOrder } from "@/lib/api"
import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers"
import { statusLabels } from "@/lib/constants" import { statusLabels } from "@/lib/constants"
import type { OrderStatus } from "@/lib/types" import type { OrderStatus } from "@/lib/types"
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react" import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
@@ -53,7 +52,6 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
undefined, undefined,
) )
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [nowTs, setNowTs] = useState(0)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -115,17 +113,6 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
} }
}, [id]) }, [id])
useEffect(() => {
if (!order) return
if (order.status !== "pending_accept" && order.status !== "pending_close") return
const timer = setInterval(() => {
setNowTs(Date.now())
}, 1000)
return () => clearInterval(timer)
}, [order])
if (loading) { if (loading) {
return ( return (
<div className="container mx-auto max-w-2xl px-4 py-8"> <div className="container mx-auto max-w-2xl px-4 py-8">
@@ -150,21 +137,6 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
? cancelledStatusSteps ? cancelledStatusSteps
: normalStatusSteps : normalStatusSteps
const currentStepIndex = statusSteps.indexOf(order.status) const currentStepIndex = statusSteps.indexOf(order.status)
const timeoutHint = (() => {
if (order.status !== "pending_accept" && order.status !== "pending_close") return null
const base =
order.status === "pending_accept"
? new Date(order.createdAt).getTime()
: new Date(order.createdAt).getTime()
const timeoutMs =
order.status === "pending_accept" ? ORDER_ACCEPT_TIMEOUT_MS : ORDER_CLOSE_TIMEOUT_MS
const remainSeconds = Math.max(0, Math.ceil((timeoutMs - (nowTs - base)) / 1000))
return order.status === "pending_accept"
? `${Math.floor(ORDER_ACCEPT_TIMEOUT_MS / 1000)} 秒内无人接单,订单将自动取消(剩余 ${remainSeconds} 秒)`
: `${Math.floor(ORDER_CLOSE_TIMEOUT_MS / 1000)} 秒内未确认,订单将自动进入待评价(剩余 ${remainSeconds} 秒)`
})()
return ( return (
<div className="container mx-auto py-8 px-4 max-w-2xl"> <div className="container mx-auto py-8 px-4 max-w-2xl">
@@ -212,12 +184,6 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
</CardContent> </CardContent>
</Card> </Card>
{timeoutHint && (
<Card className="mb-6 border-border/80 shadow-sm">
<CardContent className="py-3 text-sm text-muted-foreground">{timeoutHint}</CardContent>
</Card>
)}
<Card className="mb-6 border-border/80 shadow-sm"> <Card className="mb-6 border-border/80 shadow-sm">
<CardHeader> <CardHeader>
<CardTitle className="text-base"></CardTitle> <CardTitle className="text-base"></CardTitle>
-6
View File
@@ -1,6 +0,0 @@
export const ORDER_ACCEPT_TIMEOUT_MS = 30_000
export const ORDER_CLOSE_TIMEOUT_MS = 30_000
export const ORDER_REVIEW_TIMEOUT_MS = 30_000
export const DISPUTE_TO_REVIEWING_MS = 5_000
export const DISPUTE_TO_RESOLVED_MS = 10_000
+1 -78
View File
@@ -1,5 +1,4 @@
import type { Actor } from "@/lib/actor" import type { Actor } from "@/lib/actor"
import { DISPUTE_TO_RESOLVED_MS, DISPUTE_TO_REVIEWING_MS } from "@/lib/config/demo-timers"
import { allow, deny } from "@/lib/decision" import { allow, deny } from "@/lib/decision"
import type { ApiDecision } from "@/lib/errors" import type { ApiDecision } from "@/lib/errors"
import { generateId } from "@/lib/id" import { generateId } from "@/lib/id"
@@ -50,17 +49,6 @@ interface DisputeState {
submitAppeal: (disputeId: string, actorId: string, reason: string) => ApiDecision submitAppeal: (disputeId: string, actorId: string, reason: string) => ApiDecision
} }
const progressTimers = new Map<string, ReturnType<typeof setTimeout>[]>()
function clearProgressTimers(disputeId: string) {
const timers = progressTimers.get(disputeId)
if (!timers) return
timers.forEach((timer) => {
clearTimeout(timer)
})
progressTimers.delete(disputeId)
}
function resolveParticipantActor(orderId: string, userId: string): Actor | null { function resolveParticipantActor(orderId: string, userId: string): Actor | null {
const order = useOrderStore.getState().orders.find((item) => item.id === orderId) const order = useOrderStore.getState().orders.find((item) => item.id === orderId)
if (!order) return null if (!order) return null
@@ -98,71 +86,8 @@ function notifyDispute(orderId: string, title: string, content: string) {
} }
export const useDisputeStore = create<DisputeState>((set, get) => { export const useDisputeStore = create<DisputeState>((set, get) => {
const scheduleProgress = (disputeId: string) => {
clearProgressTimers(disputeId)
const toReviewing = setTimeout(() => {
set((state) => ({
disputes: state.disputes.map((dispute) => {
if (dispute.id !== disputeId) return dispute
if (dispute.status !== "open" && dispute.status !== "appealed") return dispute
return {
...dispute,
status: "reviewing",
timeline: [
...dispute.timeline,
{
id: generateId("timeline"),
type: "reviewing",
content: "平台已受理并进入审核",
createdAt: new Date().toISOString(),
},
],
}
}),
}))
}, DISPUTE_TO_REVIEWING_MS)
const toResolved = setTimeout(() => {
let resolvedOrderId: string | null = null
set((state) => ({
disputes: state.disputes.map((dispute) => {
if (dispute.id !== disputeId) return dispute
if (dispute.status !== "reviewing" && dispute.status !== "appealed") return dispute
resolvedOrderId = dispute.orderId
return {
...dispute,
status: "resolved",
result: dispute.result ?? "partial_refund",
timeline: [
...dispute.timeline,
{
id: generateId("timeline"),
type: "resolved",
content: "平台已给出仲裁结果",
createdAt: new Date().toISOString(),
},
],
}
}),
}))
if (resolvedOrderId) {
notifyDispute(resolvedOrderId, "争议已处理", "平台已给出争议处理结果")
useOrderStore.getState().resolveDispute(resolvedOrderId)
}
clearProgressTimers(disputeId)
}, DISPUTE_TO_RESOLVED_MS)
progressTimers.set(disputeId, [toReviewing, toResolved])
}
return { return {
disputes: [], disputes: [],
getDisputeByOrderId: (orderId) => get().disputes.find((dispute) => dispute.orderId === orderId), getDisputeByOrderId: (orderId) => get().disputes.find((dispute) => dispute.orderId === orderId),
submitDispute: (input) => { submitDispute: (input) => {
const order = useOrderStore.getState().orders.find((item) => item.id === input.orderId) const order = useOrderStore.getState().orders.find((item) => item.id === input.orderId)
@@ -208,7 +133,6 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
} }
set((state) => ({ disputes: [dispute, ...state.disputes] })) set((state) => ({ disputes: [dispute, ...state.disputes] }))
scheduleProgress(dispute.id)
return { decision: allow(), dispute } return { decision: allow(), dispute }
}, },
submitResponse: (disputeId, actorId, reason, evidence) => { submitResponse: (disputeId, actorId, reason, evidence) => {
@@ -311,7 +235,6 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
notifyDispute(dispute.orderId, "争议已申诉", "申诉已提交,平台将继续复核") notifyDispute(dispute.orderId, "争议已申诉", "申诉已提交,平台将继续复核")
scheduleProgress(disputeId)
return allow() return allow()
}, },
} }
-64
View File
@@ -1,9 +1,4 @@
import type { Actor } from "@/lib/actor" import type { Actor } from "@/lib/actor"
import {
ORDER_ACCEPT_TIMEOUT_MS,
ORDER_CLOSE_TIMEOUT_MS,
ORDER_REVIEW_TIMEOUT_MS,
} from "@/lib/config/demo-timers"
import { allow, deny } from "@/lib/decision" import { allow, deny } from "@/lib/decision"
import { evaluateOrderTransition, type OrderAction } from "@/lib/domain/order-machine" import { evaluateOrderTransition, type OrderAction } from "@/lib/domain/order-machine"
import type { ApiDecision } from "@/lib/errors" import type { ApiDecision } from "@/lib/errors"
@@ -48,16 +43,8 @@ interface OrderState {
resolveDispute: (orderId: string) => OrderMutationResult resolveDispute: (orderId: string) => OrderMutationResult
} }
const orderTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const pendingCreations = new Set<string>() const pendingCreations = new Set<string>()
function clearOrderTimeout(orderId: string) {
const timer = orderTimeouts.get(orderId)
if (!timer) return
clearTimeout(timer)
orderTimeouts.delete(orderId)
}
function isParticipant(order: Order, userId: string) { function isParticipant(order: Order, userId: string) {
return order.consumerId === userId || order.playerId === userId return order.consumerId === userId || order.playerId === userId
} }
@@ -149,42 +136,6 @@ function syncChatSession(order: Order, previousStatus: OrderStatus) {
} }
} }
function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
clearOrderTimeout(orderId)
if (status !== "pending_accept" && status !== "pending_close" && status !== "pending_review") {
return
}
const timeoutMap: Record<"pending_accept" | "pending_close" | "pending_review", number> = {
pending_accept: ORDER_ACCEPT_TIMEOUT_MS,
pending_close: ORDER_CLOSE_TIMEOUT_MS,
pending_review: ORDER_REVIEW_TIMEOUT_MS,
}
const timeoutMs = timeoutMap[status]
const timer = setTimeout(() => {
const state = useOrderStore.getState()
const order = state.orders.find((item) => item.id === orderId)
if (!order || order.status !== status) {
orderTimeouts.delete(orderId)
return
}
if (status === "pending_accept") {
state.autoTimeoutPendingAccept(orderId)
} else if (status === "pending_close") {
state.autoTimeoutPendingClose(orderId)
} else if (status === "pending_review") {
state.autoTimeoutPendingReview(orderId)
}
orderTimeouts.delete(orderId)
}, timeoutMs)
orderTimeouts.set(orderId, timer)
}
function notifyOrderStatus(order: Order) { function notifyOrderStatus(order: Order) {
if (!useAuthStore.getState().notificationPrefs.order) { if (!useAuthStore.getState().notificationPrefs.order) {
return return
@@ -346,7 +297,6 @@ export const useOrderStore = create<OrderState>((set, get) => {
orders: [order, ...state.orders], orders: [order, ...state.orders],
})) }))
scheduleOrderTimeout(order.id, order.status)
return { decision: allow(), order } return { decision: allow(), order }
}, },
createPaidOrder: (input, actor) => { createPaidOrder: (input, actor) => {
@@ -437,17 +387,3 @@ export const useOrderStore = create<OrderState>((set, get) => {
resolveDispute: (orderId) => applyTransition(orderId, "RESOLVE_DISPUTE"), resolveDispute: (orderId) => applyTransition(orderId, "RESOLVE_DISPUTE"),
} }
}) })
useOrderStore.subscribe((state, prevState) => {
state.orders.forEach((order) => {
const prevOrder = prevState.orders.find((item) => item.id === order.id)
if (!prevOrder || prevOrder.status !== order.status) {
scheduleOrderTimeout(order.id, order.status)
}
})
prevState.orders.forEach((order) => {
if (!state.orders.some((item) => item.id === order.id)) {
clearOrderTimeout(order.id)
}
})
})