diff --git a/app/(main)/player/[id]/page.tsx b/app/(main)/player/[id]/page.tsx index 43e8272..b5a10f9 100644 --- a/app/(main)/player/[id]/page.tsx +++ b/app/(main)/player/[id]/page.tsx @@ -25,7 +25,7 @@ export default async function PlayerDetailPage({ params }: { params: Promise<{ i notFound() } - const playerReviews = listReviewsByTargetUser(player.id) + const playerReviews = await listReviewsByTargetUser(player.id) const playerServices = player.services && player.services.length > 0 ? player.services diff --git a/app/(main)/shop/[id]/page.tsx b/app/(main)/shop/[id]/page.tsx index cf37509..6b55c47 100644 --- a/app/(main)/shop/[id]/page.tsx +++ b/app/(main)/shop/[id]/page.tsx @@ -25,7 +25,7 @@ export default async function ShopPage({ params }: PageProps) { const [shopPlayers, allServices] = await Promise.all([listPlayersByShop(shop.id), listServices()]) const playerIds = shopPlayers.map((p) => p.id) 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] .filter((s) => s.enabled) .sort((a, b) => a.order - b.order) diff --git a/app/(order)/dispute/[id]/page.tsx b/app/(order)/dispute/[id]/page.tsx index 59d5c09..95828b1 100644 --- a/app/(order)/dispute/[id]/page.tsx +++ b/app/(order)/dispute/[id]/page.tsx @@ -6,12 +6,16 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" 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 { notifyInfo } from "@/lib/toast" 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 Image from "next/image" import Link from "next/link" @@ -33,13 +37,42 @@ const disputeStatusLabels: Record = { appealed: "申诉中", } +function deriveMinimalTimeline(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 }> }) { const { id } = use(params) const router = useRouter() const searchParams = useSearchParams() - const order = useOrderStore((state) => state.orders.find((item) => item.id === id)) const userId = useAuthStore((state) => state.user?.id) - const existingDispute = useDisputeStore((state) => state.getDisputeByOrderId(id)) + + const [loading, setLoading] = useState(true) + const [order, setOrder] = useState> | null>(null) + const [existingDispute, setExistingDispute] = useState + > | null>(null) const [reason, setReason] = useState("") const [files, setFiles] = useState([]) @@ -52,6 +85,28 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } const filesRef = useRef([]) const responseFilesRef = useRef([]) + 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(() => { filesRef.current = files }, [files]) @@ -106,19 +161,29 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } const handleSubmit = () => { if (!userId || !reason.trim()) return - const result = submitDispute({ - orderId: id, - reason, - evidence: files, + + Promise.resolve( + submitDispute({ + orderId: id, + reason, + evidence: files, + }), + ).then((result) => { + if (!result.decision.ok) { + notifyInfo(result.decision.error.msg) + return + } + router.replace(`/dispute/${id}?submitted=1`) }) - if (!result.decision.ok) { - notifyInfo(result.decision.error.msg) - return - } - router.replace(`/dispute/${id}?submitted=1`) } - const showSubmitted = searchParams.get("submitted") === "1" && !existingDispute + const showSubmitted = !loading && searchParams.get("submitted") === "1" && !existingDispute + + if (loading) { + return ( +
加载中...
+ ) + } if (!order) { return ( @@ -149,6 +214,8 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } const canAppeal = isParticipant && existingDispute.status === "resolved" && !existingDispute.appealedAt + const timeline = deriveMinimalTimeline(existingDispute) + return (
{ if (!userId) return - const decision = submitDisputeResponse({ - disputeId: existingDispute.id, - reason: responseReason, - evidence: responseFiles, + Promise.resolve( + submitDisputeResponse({ + disputeId: existingDispute.id, + reason: responseReason, + evidence: responseFiles, + }), + ).then((decision) => { + if (!decision.ok) { + notifyInfo(decision.error.msg) + } }) - if (!decision.ok) { - notifyInfo(decision.error.msg) - } }} disabled={!responseReason.trim()} > @@ -331,13 +401,16 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } variant="outline" onClick={() => { if (!userId) return - const decision = submitDisputeAppeal({ - disputeId: existingDispute.id, - reason: appealReason, + Promise.resolve( + submitDisputeAppeal({ + disputeId: existingDispute.id, + reason: appealReason, + }), + ).then((decision) => { + if (!decision.ok) { + notifyInfo(decision.error.msg) + } }) - if (!decision.ok) { - notifyInfo(decision.error.msg) - } }} disabled={!appealReason.trim()} > @@ -352,7 +425,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
- {existingDispute.timeline.map((item) => ( + {timeline.map((item) => (
{item.content} diff --git a/app/(order)/order/[id]/page.tsx b/app/(order)/order/[id]/page.tsx index b89a3da..69695cd 100644 --- a/app/(order)/order/[id]/page.tsx +++ b/app/(order)/order/[id]/page.tsx @@ -4,15 +4,14 @@ import OrderActions from "@/components/order-actions" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 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 { statusLabels } from "@/lib/constants" import type { OrderStatus } from "@/lib/types" import { useChatStore } from "@/store/chat" -import { useReviewStore } from "@/store/reviews" import { ArrowLeft, CheckCircle, Clock, Star } from "lucide-react" import Link from "next/link" -import { use, useEffect, useMemo, useState } from "react" +import { use, useEffect, useState } from "react" const normalStatusSteps: OrderStatus[] = [ "pending_payment", @@ -36,12 +35,7 @@ const cancelledStatusSteps: OrderStatus[] = ["pending_payment", "pending_accept" export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const sessions = useChatStore((state) => state.sessions) - const allReviews = useReviewStore((state) => state.reviews) - // 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 [reviews, setReviews] = useState>>([]) const [order, setOrder] = useState> | undefined>( undefined, ) @@ -70,6 +64,25 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri } }, [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(() => { if (!order) return if (order.status !== "pending_accept" && order.status !== "pending_close") return diff --git a/app/(order)/review/[id]/page.tsx b/app/(order)/review/[id]/page.tsx index 54dcb47..0a4dcf1 100644 --- a/app/(order)/review/[id]/page.tsx +++ b/app/(order)/review/[id]/page.tsx @@ -4,29 +4,56 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" +import { getOrderById, listReviewsByOrder } from "@/lib/api" import { submitReview } from "@/lib/api/reviews" import { notifyInfo, notifySuccess } from "@/lib/toast" import { useAuthStore } from "@/store/auth" -import { useOrderStore } from "@/store/orders" -import { useReviewStore } from "@/store/reviews" import { ArrowLeft, Lock, Star } from "lucide-react" 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 }> }) { const { id } = use(params) - const order = useOrderStore((state) => state.orders.find((item) => item.id === 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. - // This keeps useSyncExternalStore snapshots stable across render checks. - // Inline filter inside the selector creates a new array reference each call - // 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 [order, setOrder] = useState>>() + const [reviews, setReviews] = useState>>([]) + const [loading, setLoading] = useState(true) const [rating, setRating] = useState(0) const [hoverRating, setHoverRating] = useState(0) 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 ( +
加载中...
+ ) + } + if (!order) { return (
@@ -143,18 +170,23 @@ export default function ReviewPage({ params }: { params: Promise<{ id: string }> return } - const decision = submitReview({ - orderId: id, - rating, - content, + Promise.resolve( + submitReview({ + orderId: id, + rating, + content, + }), + ).then((decision) => { + if (decision.ok) { + notifySuccess("评价已提交") + Promise.resolve(listReviewsByOrder(id)).then((nextReviews) => { + setReviews(nextReviews) + }) + return + } + + notifyInfo(decision.error.msg) }) - - if (decision.ok) { - notifySuccess("评价已提交") - return - } - - notifyInfo(decision.error.msg) }} > 提交评价 diff --git a/lib/api/disputes.ts b/lib/api/disputes.ts index b11a0dc..79e4f27 100644 --- a/lib/api/disputes.ts +++ b/lib/api/disputes.ts @@ -1,50 +1,189 @@ -import { deny } from "@/lib/decision" -import { useAuthStore } from "@/store/auth" -import { useDisputeStore } from "@/store/disputes" +import { allow, deny } from "@/lib/decision" +import { isApiError, toApiError, type ApiDecision } from "@/lib/errors" +import type { Dispute } from "@/lib/types" -export function listDisputes() { - return useDisputeStore.getState().disputes +import { httpJson } from "./http" + +export type DisputeTimelineItem = { + id: string + content: string + createdAt: string } -export function getDisputeByOrderId(orderId: string) { - return useDisputeStore.getState().disputes.find((dispute) => dispute.orderId === orderId) +export type DisputeRecord = Dispute & { + respondentReason?: string + respondentEvidence: string[] + appealReason?: string + appealedAt?: string + timeline: DisputeTimelineItem[] } -export function submitDispute(input: { orderId: string; reason: string; evidence: string[] }) { - const user = useAuthStore.getState().user - if (!user?.id || !user.nickname) { - return { decision: deny(401, "请先登录") } +export type ListDisputesOptions = { + offset?: number + limit?: number +} + +type Paginated = { + 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(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 { + const res = await httpJson | DisputeRecord[]>( + withOffsetLimit("/api/v1/disputes", options), + { cache: "no-store" }, + ) + return unwrapItems(res).map((item) => normalizeDisputeRecord(item)) +} + +export async function getDisputeByOrderId(orderId: string): Promise { + try { + const res = await httpJson(`/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(`/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 reason: string evidence: string[] -}) { - const userId = useAuthStore.getState().user?.id - if (!userId) { - return deny(401, "请先登录") +}): Promise { + try { + await httpJson(`/api/v1/disputes/${encodeURIComponent(input.disputeId)}/response`, { + 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 }) { - const userId = useAuthStore.getState().user?.id - if (!userId) { - return deny(401, "请先登录") +export async function submitDisputeAppeal(input: { + disputeId: string + reason: string +}): Promise { + try { + await httpJson(`/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) } diff --git a/lib/api/reviews.ts b/lib/api/reviews.ts index fd7c946..e989f87 100644 --- a/lib/api/reviews.ts +++ b/lib/api/reviews.ts @@ -1,29 +1,89 @@ -import { deny } from "@/lib/decision" -import { useAuthStore } from "@/store/auth" -import { useReviewStore } from "@/store/reviews" +import { allow, deny } from "@/lib/decision" +import { toApiError, type ApiDecision } from "@/lib/errors" +import type { Review } from "@/lib/types" -export function listReviews() { - return useReviewStore.getState().reviews -} +import { httpJson } from "./http" -export function listReviewsByOrder(orderId: string) { - return useReviewStore.getState().reviews.filter((review) => review.orderId === orderId) -} - -export function listReviewsByTargetUser(userId: string) { - return useReviewStore.getState().reviews.filter((review) => review.toUserId === userId) -} - -export function submitReview(input: { orderId: string; rating: number; content?: string }) { - const userId = useAuthStore.getState().user?.id - if (!userId) { - return deny(401, "请先登录") +type Paginated = { + items: T[] + meta: { + total: number + offset: number + limit: number + } +} + +export type ListReviewsOptions = { + offset?: number + limit?: number +} + +function withOffsetLimit(path: string, options?: ListReviewsOptions): 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(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") +} + +export async function listReviews(options?: ListReviewsOptions): Promise { + const res = await httpJson | Review[]>( + withOffsetLimit("/api/v1/reviews", options), + { + cache: "no-store", + }, + ) + return unwrapItems(res) +} + +export async function listReviewsByOrder(orderId: string): Promise { + const res = await httpJson | Review[]>( + `/api/v1/orders/${encodeURIComponent(orderId)}/reviews`, + { cache: "no-store" }, + ) + return unwrapItems(res) +} + +export async function listReviewsByTargetUser(userId: string): Promise { + const res = await httpJson | Review[]>( + `/api/v1/users/${encodeURIComponent(userId)}/reviews`, + { cache: "no-store" }, + ) + return unwrapItems(res) +} + +export async function submitReview(input: { + orderId: string + rating: number + content?: string +}): Promise { + try { + await httpJson(`/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, "请先登录") + } + const apiError = toApiError(error) + return deny(apiError.code, apiError.msg) } - - return useReviewStore.getState().submitReview({ - orderId: input.orderId, - fromUserId: userId, - rating: input.rating, - content: input.content, - }) }