feat(disputes): migrate disputes and reviews to backend API

This commit is contained in:
zetaloop
2026-03-01 16:25:33 +08:00
parent 9739c94bdc
commit f189ec9846
7 changed files with 437 additions and 120 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+86 -13
View File
@@ -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">
+22 -9
View File
@@ -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
+45 -13
View File
@@ -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)
})
}} }}
> >
+172 -33
View File
@@ -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, "请先登录") }
} }
return useDisputeStore.getState().submitDispute({ type Paginated<T> = {
orderId: input.orderId, items: T[]
initiatorId: user.id, meta?: {
initiatorName: user.nickname, total: number
reason: input.reason, offset: number
evidence: input.evidence, limit: number
}
}
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()}`
} }
export function submitDisputeResponse(input: { 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 export async function submitDisputeAppeal(input: {
.getState() disputeId: string
.submitResponse(input.disputeId, userId, input.reason, input.evidence) reason: string
}): 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)
} }
export function submitDisputeAppeal(input: { disputeId: string; reason: string }) {
const userId = useAuthStore.getState().user?.id
if (!userId) {
return deny(401, "请先登录")
}
return useDisputeStore.getState().submitAppeal(input.disputeId, userId, input.reason)
} }
+79 -19
View File
@@ -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,
})
} }