feat(disputes): migrate disputes and reviews to backend API
This commit is contained in:
@@ -25,7 +25,7 @@ export default async function PlayerDetailPage({ params }: { params: Promise<{ i
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerReviews = listReviewsByTargetUser(player.id)
|
const playerReviews = await listReviewsByTargetUser(player.id)
|
||||||
const playerServices =
|
const playerServices =
|
||||||
player.services && player.services.length > 0
|
player.services && player.services.length > 0
|
||||||
? player.services
|
? player.services
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default async function ShopPage({ params }: PageProps) {
|
|||||||
const [shopPlayers, allServices] = await Promise.all([listPlayersByShop(shop.id), listServices()])
|
const [shopPlayers, allServices] = await Promise.all([listPlayersByShop(shop.id), listServices()])
|
||||||
const playerIds = shopPlayers.map((p) => p.id)
|
const playerIds = shopPlayers.map((p) => p.id)
|
||||||
const shopServices = allServices.filter((s) => playerIds.includes(s.playerId))
|
const shopServices = allServices.filter((s) => playerIds.includes(s.playerId))
|
||||||
const shopReviews = listReviews().filter((r) => playerIds.includes(r.toUserId))
|
const shopReviews = (await listReviews()).filter((r) => playerIds.includes(r.toUserId))
|
||||||
const sortedSections = [...shop.templateConfig.sections]
|
const sortedSections = [...shop.templateConfig.sections]
|
||||||
.filter((s) => s.enabled)
|
.filter((s) => s.enabled)
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { submitDispute, submitDisputeAppeal, submitDisputeResponse } from "@/lib/api/disputes"
|
import { getOrderById } from "@/lib/api"
|
||||||
|
import {
|
||||||
|
getDisputeByOrderId,
|
||||||
|
submitDispute,
|
||||||
|
submitDisputeAppeal,
|
||||||
|
submitDisputeResponse,
|
||||||
|
} from "@/lib/api/disputes"
|
||||||
import { DISPUTE_TO_RESOLVED_MS } from "@/lib/config/demo-timers"
|
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 { useDisputeStore } from "@/store/disputes"
|
|
||||||
import { useOrderStore } from "@/store/orders"
|
|
||||||
import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-react"
|
import { AlertTriangle, ArrowLeft, Clock, FileText, Upload, X } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@@ -33,13 +37,42 @@ const disputeStatusLabels: Record<string, string> = {
|
|||||||
appealed: "申诉中",
|
appealed: "申诉中",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveMinimalTimeline<TCreatedAt>(dispute: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
createdAt: TCreatedAt
|
||||||
|
timeline?: { id: string; content: string; createdAt: TCreatedAt }[]
|
||||||
|
}) {
|
||||||
|
const existing = dispute.timeline
|
||||||
|
if (existing?.length) return existing
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ status: "open", content: "争议已提交" },
|
||||||
|
{ status: "reviewing", content: "平台审核中" },
|
||||||
|
{ status: "resolved", content: "争议已解决" },
|
||||||
|
{ status: "appealed", content: "已发起申诉" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentIndex = steps.findIndex((step) => step.status === dispute.status)
|
||||||
|
const lastIndex = currentIndex >= 0 ? currentIndex : 0
|
||||||
|
return steps.slice(0, lastIndex + 1).map((step) => ({
|
||||||
|
id: `${dispute.id}-${step.status}`,
|
||||||
|
content: step.content,
|
||||||
|
createdAt: dispute.createdAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export default function DisputePage({ params }: { params: Promise<{ id: string }> }) {
|
export default function DisputePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const order = useOrderStore((state) => state.orders.find((item) => item.id === id))
|
|
||||||
const userId = useAuthStore((state) => state.user?.id)
|
const userId = useAuthStore((state) => state.user?.id)
|
||||||
const existingDispute = useDisputeStore((state) => state.getDisputeByOrderId(id))
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | null>(null)
|
||||||
|
const [existingDispute, setExistingDispute] = useState<Awaited<
|
||||||
|
ReturnType<typeof getDisputeByOrderId>
|
||||||
|
> | null>(null)
|
||||||
|
|
||||||
const [reason, setReason] = useState("")
|
const [reason, setReason] = useState("")
|
||||||
const [files, setFiles] = useState<string[]>([])
|
const [files, setFiles] = useState<string[]>([])
|
||||||
@@ -52,6 +85,28 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
|||||||
const filesRef = useRef<string[]>([])
|
const filesRef = useRef<string[]>([])
|
||||||
const responseFilesRef = useRef<string[]>([])
|
const responseFilesRef = useRef<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setOrder(null)
|
||||||
|
setExistingDispute(null)
|
||||||
|
|
||||||
|
Promise.all([Promise.resolve(getOrderById(id)), Promise.resolve(getDisputeByOrderId(id))])
|
||||||
|
.then(([nextOrder, nextDispute]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setOrder(nextOrder ?? null)
|
||||||
|
setExistingDispute(nextDispute ?? null)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
filesRef.current = files
|
filesRef.current = files
|
||||||
}, [files])
|
}, [files])
|
||||||
@@ -106,19 +161,29 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
|||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!userId || !reason.trim()) return
|
if (!userId || !reason.trim()) return
|
||||||
const result = submitDispute({
|
|
||||||
|
Promise.resolve(
|
||||||
|
submitDispute({
|
||||||
orderId: id,
|
orderId: id,
|
||||||
reason,
|
reason,
|
||||||
evidence: files,
|
evidence: files,
|
||||||
})
|
}),
|
||||||
|
).then((result) => {
|
||||||
if (!result.decision.ok) {
|
if (!result.decision.ok) {
|
||||||
notifyInfo(result.decision.error.msg)
|
notifyInfo(result.decision.error.msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.replace(`/dispute/${id}?submitted=1`)
|
router.replace(`/dispute/${id}?submitted=1`)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const showSubmitted = searchParams.get("submitted") === "1" && !existingDispute
|
const showSubmitted = !loading && searchParams.get("submitted") === "1" && !existingDispute
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">加载中...</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return (
|
return (
|
||||||
@@ -149,6 +214,8 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
|||||||
const canAppeal =
|
const canAppeal =
|
||||||
isParticipant && existingDispute.status === "resolved" && !existingDispute.appealedAt
|
isParticipant && existingDispute.status === "resolved" && !existingDispute.appealedAt
|
||||||
|
|
||||||
|
const timeline = deriveMinimalTimeline(existingDispute)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-4">
|
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-4">
|
||||||
<Link
|
<Link
|
||||||
@@ -282,14 +349,17 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
const decision = submitDisputeResponse({
|
Promise.resolve(
|
||||||
|
submitDisputeResponse({
|
||||||
disputeId: existingDispute.id,
|
disputeId: existingDispute.id,
|
||||||
reason: responseReason,
|
reason: responseReason,
|
||||||
evidence: responseFiles,
|
evidence: responseFiles,
|
||||||
})
|
}),
|
||||||
|
).then((decision) => {
|
||||||
if (!decision.ok) {
|
if (!decision.ok) {
|
||||||
notifyInfo(decision.error.msg)
|
notifyInfo(decision.error.msg)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
disabled={!responseReason.trim()}
|
disabled={!responseReason.trim()}
|
||||||
>
|
>
|
||||||
@@ -331,13 +401,16 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
const decision = submitDisputeAppeal({
|
Promise.resolve(
|
||||||
|
submitDisputeAppeal({
|
||||||
disputeId: existingDispute.id,
|
disputeId: existingDispute.id,
|
||||||
reason: appealReason,
|
reason: appealReason,
|
||||||
})
|
}),
|
||||||
|
).then((decision) => {
|
||||||
if (!decision.ok) {
|
if (!decision.ok) {
|
||||||
notifyInfo(decision.error.msg)
|
notifyInfo(decision.error.msg)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
disabled={!appealReason.trim()}
|
disabled={!appealReason.trim()}
|
||||||
>
|
>
|
||||||
@@ -352,7 +425,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">处理时间线</Label>
|
<Label className="text-muted-foreground">处理时间线</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{existingDispute.timeline.map((item) => (
|
{timeline.map((item) => (
|
||||||
<div key={item.id} className="text-sm flex items-start justify-between gap-3">
|
<div key={item.id} className="text-sm flex items-start justify-between gap-3">
|
||||||
<span>{item.content}</span>
|
<span>{item.content}</span>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ import OrderActions from "@/components/order-actions"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { getOrderById } from "@/lib/api"
|
import { getOrderById, listReviewsByOrder } from "@/lib/api"
|
||||||
import { ORDER_ACCEPT_TIMEOUT_MS, ORDER_CLOSE_TIMEOUT_MS } from "@/lib/config/demo-timers"
|
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 { useChatStore } from "@/store/chat"
|
import { useChatStore } from "@/store/chat"
|
||||||
import { useReviewStore } from "@/store/reviews"
|
|
||||||
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
|
import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { use, useEffect, useMemo, useState } from "react"
|
import { use, useEffect, useState } from "react"
|
||||||
|
|
||||||
const normalStatusSteps: OrderStatus[] = [
|
const normalStatusSteps: OrderStatus[] = [
|
||||||
"pending_payment",
|
"pending_payment",
|
||||||
@@ -36,12 +35,7 @@ const cancelledStatusSteps: OrderStatus[] = ["pending_payment", "pending_accept"
|
|||||||
export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
const sessions = useChatStore((state) => state.sessions)
|
const sessions = useChatStore((state) => state.sessions)
|
||||||
const allReviews = useReviewStore((state) => state.reviews)
|
const [reviews, setReviews] = useState<Awaited<ReturnType<typeof listReviewsByOrder>>>([])
|
||||||
// Filtering is deferred to useMemo after reading the raw store array.
|
|
||||||
// Zustand v5 compares selector outputs by reference stability.
|
|
||||||
// Returning a fresh filtered array from the selector can re-trigger updates
|
|
||||||
// and loop under useSyncExternalStore (pmndrs/zustand#1936, #3155).
|
|
||||||
const reviews = useMemo(() => allReviews.filter((item) => item.orderId === id), [allReviews, id])
|
|
||||||
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | undefined>(
|
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>> | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
)
|
)
|
||||||
@@ -70,6 +64,25 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
|||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const reviews = await Promise.resolve(listReviewsByOrder(id))
|
||||||
|
if (cancelled) return
|
||||||
|
setReviews(reviews)
|
||||||
|
} catch {
|
||||||
|
if (cancelled) return
|
||||||
|
setReviews([])
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!order) return
|
if (!order) return
|
||||||
if (order.status !== "pending_accept" && order.status !== "pending_close") return
|
if (order.status !== "pending_accept" && order.status !== "pending_close") return
|
||||||
|
|||||||
@@ -4,29 +4,56 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { getOrderById, listReviewsByOrder } from "@/lib/api"
|
||||||
import { submitReview } from "@/lib/api/reviews"
|
import { submitReview } from "@/lib/api/reviews"
|
||||||
import { notifyInfo, notifySuccess } from "@/lib/toast"
|
import { notifyInfo, notifySuccess } from "@/lib/toast"
|
||||||
import { useAuthStore } from "@/store/auth"
|
import { useAuthStore } from "@/store/auth"
|
||||||
import { useOrderStore } from "@/store/orders"
|
|
||||||
import { useReviewStore } from "@/store/reviews"
|
|
||||||
import { ArrowLeft, Lock, Star } from "lucide-react"
|
import { ArrowLeft, Lock, Star } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { use, useMemo, useState } from "react"
|
import { use, useEffect, useState } from "react"
|
||||||
|
|
||||||
export default function ReviewPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function ReviewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
const order = useOrderStore((state) => state.orders.find((item) => item.id === id))
|
|
||||||
const userId = useAuthStore((state) => state.user?.id)
|
const userId = useAuthStore((state) => state.user?.id)
|
||||||
const allReviews = useReviewStore((state) => state.reviews)
|
|
||||||
// The selector returns the raw store array and useMemo derives the subset.
|
const [order, setOrder] = useState<Awaited<ReturnType<typeof getOrderById>>>()
|
||||||
// This keeps useSyncExternalStore snapshots stable across render checks.
|
const [reviews, setReviews] = useState<Awaited<ReturnType<typeof listReviewsByOrder>>>([])
|
||||||
// Inline filter inside the selector creates a new array reference each call
|
const [loading, setLoading] = useState(true)
|
||||||
// and can cause infinite re-render loops in Zustand v5 (pmndrs/zustand#3155).
|
|
||||||
const reviews = useMemo(() => allReviews.filter((item) => item.orderId === id), [allReviews, id])
|
|
||||||
const [rating, setRating] = useState(0)
|
const [rating, setRating] = useState(0)
|
||||||
const [hoverRating, setHoverRating] = useState(0)
|
const [hoverRating, setHoverRating] = useState(0)
|
||||||
const [content, setContent] = useState("")
|
const [content, setContent] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
Promise.all([getOrderById(id), Promise.resolve(listReviewsByOrder(id))])
|
||||||
|
.then(([nextOrder, nextReviews]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setOrder(nextOrder)
|
||||||
|
setReviews(nextReviews)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setOrder(undefined)
|
||||||
|
setReviews([])
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">加载中...</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
|
<div className="container mx-auto py-8 px-4 text-center text-muted-foreground">
|
||||||
@@ -143,18 +170,23 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }>
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const decision = submitReview({
|
Promise.resolve(
|
||||||
|
submitReview({
|
||||||
orderId: id,
|
orderId: id,
|
||||||
rating,
|
rating,
|
||||||
content,
|
content,
|
||||||
})
|
}),
|
||||||
|
).then((decision) => {
|
||||||
if (decision.ok) {
|
if (decision.ok) {
|
||||||
notifySuccess("评价已提交")
|
notifySuccess("评价已提交")
|
||||||
|
Promise.resolve(listReviewsByOrder(id)).then((nextReviews) => {
|
||||||
|
setReviews(nextReviews)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyInfo(decision.error.msg)
|
notifyInfo(decision.error.msg)
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
提交评价
|
提交评价
|
||||||
|
|||||||
+173
-34
@@ -1,50 +1,189 @@
|
|||||||
import { deny } from "@/lib/decision"
|
import { allow, deny } from "@/lib/decision"
|
||||||
import { useAuthStore } from "@/store/auth"
|
import { isApiError, toApiError, type ApiDecision } from "@/lib/errors"
|
||||||
import { useDisputeStore } from "@/store/disputes"
|
import type { Dispute } from "@/lib/types"
|
||||||
|
|
||||||
export function listDisputes() {
|
import { httpJson } from "./http"
|
||||||
return useDisputeStore.getState().disputes
|
|
||||||
|
export type DisputeTimelineItem = {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDisputeByOrderId(orderId: string) {
|
export type DisputeRecord = Dispute & {
|
||||||
return useDisputeStore.getState().disputes.find((dispute) => dispute.orderId === orderId)
|
respondentReason?: string
|
||||||
|
respondentEvidence: string[]
|
||||||
|
appealReason?: string
|
||||||
|
appealedAt?: string
|
||||||
|
timeline: DisputeTimelineItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitDispute(input: { orderId: string; reason: string; evidence: string[] }) {
|
export type ListDisputesOptions = {
|
||||||
const user = useAuthStore.getState().user
|
offset?: number
|
||||||
if (!user?.id || !user.nickname) {
|
limit?: number
|
||||||
return { decision: deny(401, "请先登录") }
|
}
|
||||||
|
|
||||||
|
type Paginated<T> = {
|
||||||
|
items: T[]
|
||||||
|
meta?: {
|
||||||
|
total: number
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
}
|
}
|
||||||
|
|
||||||
return useDisputeStore.getState().submitDispute({
|
|
||||||
orderId: input.orderId,
|
|
||||||
initiatorId: user.id,
|
|
||||||
initiatorName: user.nickname,
|
|
||||||
reason: input.reason,
|
|
||||||
evidence: input.evidence,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitDisputeResponse(input: {
|
function withOffsetLimit(path: string, options?: ListDisputesOptions): string {
|
||||||
|
const offset = options?.offset ?? 0
|
||||||
|
const limit = options?.limit ?? 1000
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
offset: String(offset),
|
||||||
|
limit: String(limit),
|
||||||
|
})
|
||||||
|
|
||||||
|
return `${path}?${searchParams.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapItems<T>(value: unknown): T[] {
|
||||||
|
if (Array.isArray(value)) return value as T[]
|
||||||
|
if (typeof value !== "object" || value === null) throw new Error("Invalid response")
|
||||||
|
if ("items" in value) {
|
||||||
|
const envelope = value as { items?: unknown }
|
||||||
|
if (Array.isArray(envelope.items)) return envelope.items as T[]
|
||||||
|
}
|
||||||
|
throw new Error("Invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapDispute(value: unknown): unknown {
|
||||||
|
if (typeof value !== "object" || value === null) return value
|
||||||
|
if ("dispute" in value) {
|
||||||
|
const envelope = value as { dispute?: unknown }
|
||||||
|
return envelope.dispute
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveMinimalTimeline(dispute: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
createdAt: string
|
||||||
|
timeline?: DisputeTimelineItem[]
|
||||||
|
}): DisputeTimelineItem[] {
|
||||||
|
const existing = dispute.timeline
|
||||||
|
if (existing?.length) return existing
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ status: "open", content: "争议已提交" },
|
||||||
|
{ status: "reviewing", content: "平台审核中" },
|
||||||
|
{ status: "resolved", content: "争议已解决" },
|
||||||
|
{ status: "appealed", content: "已发起申诉" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentIndex = steps.findIndex((step) => step.status === dispute.status)
|
||||||
|
const lastIndex = currentIndex >= 0 ? currentIndex : 0
|
||||||
|
return steps.slice(0, lastIndex + 1).map((step) => ({
|
||||||
|
id: `${dispute.id}-${step.status}`,
|
||||||
|
content: step.content,
|
||||||
|
createdAt: dispute.createdAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisputeRecord(value: unknown): DisputeRecord {
|
||||||
|
const dispute = unwrapDispute(value) as DisputeRecord
|
||||||
|
const respondentEvidence = Array.isArray(dispute.respondentEvidence)
|
||||||
|
? dispute.respondentEvidence
|
||||||
|
: []
|
||||||
|
const evidence = Array.isArray(dispute.evidence) ? dispute.evidence : []
|
||||||
|
const timeline = deriveMinimalTimeline({
|
||||||
|
id: dispute.id,
|
||||||
|
status: dispute.status,
|
||||||
|
createdAt: dispute.createdAt,
|
||||||
|
timeline: dispute.timeline,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...dispute,
|
||||||
|
evidence,
|
||||||
|
respondentEvidence,
|
||||||
|
timeline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function denyFromError(error: unknown): ApiDecision {
|
||||||
|
if (error instanceof Error && error.message === "UNAUTHORIZED") {
|
||||||
|
return deny(401, "请先登录")
|
||||||
|
}
|
||||||
|
const apiError = toApiError(error)
|
||||||
|
return deny(apiError.code, apiError.msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDisputes(options?: ListDisputesOptions): Promise<DisputeRecord[]> {
|
||||||
|
const res = await httpJson<Paginated<DisputeRecord> | DisputeRecord[]>(
|
||||||
|
withOffsetLimit("/api/v1/disputes", options),
|
||||||
|
{ cache: "no-store" },
|
||||||
|
)
|
||||||
|
return unwrapItems<DisputeRecord>(res).map((item) => normalizeDisputeRecord(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDisputeByOrderId(orderId: string): Promise<DisputeRecord | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(orderId)}/dispute`, {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
return normalizeDisputeRecord(res)
|
||||||
|
} catch (error) {
|
||||||
|
const apiError = isApiError(error) ? error : toApiError(error)
|
||||||
|
if (apiError.code === 404) return undefined
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitDispute(input: {
|
||||||
|
orderId: string
|
||||||
|
reason: string
|
||||||
|
evidence: string[]
|
||||||
|
}): Promise<{ decision: ApiDecision }> {
|
||||||
|
try {
|
||||||
|
await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(input.orderId)}/dispute`, {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
json: { reason: input.reason, evidence: input.evidence },
|
||||||
|
})
|
||||||
|
return { decision: allow() }
|
||||||
|
} catch (error) {
|
||||||
|
return { decision: denyFromError(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitDisputeResponse(input: {
|
||||||
disputeId: string
|
disputeId: string
|
||||||
reason: string
|
reason: string
|
||||||
evidence: string[]
|
evidence: string[]
|
||||||
}) {
|
}): Promise<ApiDecision> {
|
||||||
const userId = useAuthStore.getState().user?.id
|
try {
|
||||||
if (!userId) {
|
await httpJson<unknown>(`/api/v1/disputes/${encodeURIComponent(input.disputeId)}/response`, {
|
||||||
return deny(401, "请先登录")
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
json: { reason: input.reason, evidence: input.evidence },
|
||||||
|
})
|
||||||
|
return allow()
|
||||||
|
} catch (error) {
|
||||||
|
return denyFromError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return useDisputeStore
|
|
||||||
.getState()
|
|
||||||
.submitResponse(input.disputeId, userId, input.reason, input.evidence)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitDisputeAppeal(input: { disputeId: string; reason: string }) {
|
export async function submitDisputeAppeal(input: {
|
||||||
const userId = useAuthStore.getState().user?.id
|
disputeId: string
|
||||||
if (!userId) {
|
reason: string
|
||||||
return deny(401, "请先登录")
|
}): Promise<ApiDecision> {
|
||||||
|
try {
|
||||||
|
await httpJson<unknown>(`/api/v1/disputes/${encodeURIComponent(input.disputeId)}/appeal`, {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
json: { reason: input.reason },
|
||||||
|
})
|
||||||
|
return allow()
|
||||||
|
} catch (error) {
|
||||||
|
return denyFromError(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return useDisputeStore.getState().submitAppeal(input.disputeId, userId, input.reason)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+79
-19
@@ -1,29 +1,89 @@
|
|||||||
import { deny } from "@/lib/decision"
|
import { allow, deny } from "@/lib/decision"
|
||||||
import { useAuthStore } from "@/store/auth"
|
import { toApiError, type ApiDecision } from "@/lib/errors"
|
||||||
import { useReviewStore } from "@/store/reviews"
|
import type { Review } from "@/lib/types"
|
||||||
|
|
||||||
export function listReviews() {
|
import { httpJson } from "./http"
|
||||||
return useReviewStore.getState().reviews
|
|
||||||
|
type Paginated<T> = {
|
||||||
|
items: T[]
|
||||||
|
meta: {
|
||||||
|
total: number
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listReviewsByOrder(orderId: string) {
|
export type ListReviewsOptions = {
|
||||||
return useReviewStore.getState().reviews.filter((review) => review.orderId === orderId)
|
offset?: number
|
||||||
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listReviewsByTargetUser(userId: string) {
|
function withOffsetLimit(path: string, options?: ListReviewsOptions): string {
|
||||||
return useReviewStore.getState().reviews.filter((review) => review.toUserId === userId)
|
const offset = options?.offset ?? 0
|
||||||
|
const limit = options?.limit ?? 1000
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
offset: String(offset),
|
||||||
|
limit: String(limit),
|
||||||
|
})
|
||||||
|
return `${path}?${searchParams.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitReview(input: { orderId: string; rating: number; content?: string }) {
|
function unwrapItems<T>(value: unknown): T[] {
|
||||||
const userId = useAuthStore.getState().user?.id
|
if (Array.isArray(value)) return value as T[]
|
||||||
if (!userId) {
|
if (typeof value !== "object" || value === null) {
|
||||||
|
throw new Error("Invalid response")
|
||||||
|
}
|
||||||
|
if ("items" in value) {
|
||||||
|
const envelope = value as { items?: unknown }
|
||||||
|
if (Array.isArray(envelope.items)) return envelope.items as T[]
|
||||||
|
}
|
||||||
|
throw new Error("Invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReviews(options?: ListReviewsOptions): Promise<Review[]> {
|
||||||
|
const res = await httpJson<Paginated<Review> | Review[]>(
|
||||||
|
withOffsetLimit("/api/v1/reviews", options),
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return unwrapItems<Review>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReviewsByOrder(orderId: string): Promise<Review[]> {
|
||||||
|
const res = await httpJson<Paginated<Review> | Review[]>(
|
||||||
|
`/api/v1/orders/${encodeURIComponent(orderId)}/reviews`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
)
|
||||||
|
return unwrapItems<Review>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReviewsByTargetUser(userId: string): Promise<Review[]> {
|
||||||
|
const res = await httpJson<Paginated<Review> | Review[]>(
|
||||||
|
`/api/v1/users/${encodeURIComponent(userId)}/reviews`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
)
|
||||||
|
return unwrapItems<Review>(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitReview(input: {
|
||||||
|
orderId: string
|
||||||
|
rating: number
|
||||||
|
content?: string
|
||||||
|
}): Promise<ApiDecision> {
|
||||||
|
try {
|
||||||
|
await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(input.orderId)}/review`, {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
json: { rating: input.rating, content: input.content },
|
||||||
|
})
|
||||||
|
return allow()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "UNAUTHORIZED") {
|
||||||
return deny(401, "请先登录")
|
return deny(401, "请先登录")
|
||||||
}
|
}
|
||||||
|
const apiError = toApiError(error)
|
||||||
return useReviewStore.getState().submitReview({
|
return deny(apiError.code, apiError.msg)
|
||||||
orderId: input.orderId,
|
}
|
||||||
fromUserId: userId,
|
|
||||||
rating: input.rating,
|
|
||||||
content: input.content,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user