Compare commits
10 Commits
1f20198f23
...
7b191c5d6e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b191c5d6e | |||
| 4f878340e6 | |||
| 48effb4eeb | |||
| a0b61fbc44 | |||
| 38ff65d51f | |||
| 88eb9727b5 | |||
| 7acde68d45 | |||
| be329865b3 | |||
| 0e7270aa8d | |||
| a3f0b49112 |
@@ -0,0 +1,14 @@
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
node_modules
|
||||
.env*
|
||||
!.env.example
|
||||
.vscode
|
||||
.DS_Store
|
||||
README*
|
||||
*.md
|
||||
.serena
|
||||
.sisyphus
|
||||
.claude
|
||||
.idea
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM node:25-alpine AS base
|
||||
RUN npm install -g --force corepack@latest && corepack enable
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=::
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,5 +1,7 @@
|
||||
# 聚玩 — 产品设计计划
|
||||
|
||||
> 本文描述产品设计意图,**不代表已实现的功能**。资金托管/释放、争议仲裁、自动派单、浏览器推送等条目目前仍属于规划方向,前端可能有占位 UI 但后端尚未提供完整实现。
|
||||
|
||||
## 一、用户系统
|
||||
|
||||
一个账号,三种身份:消费者、打手、店主。每个身份有独立的主页,身份切换是全局的(切换后整个应用的视角和导航都随之改变)。
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- Node.js 25+
|
||||
- pnpm 10+
|
||||
|
||||
### 安装依赖
|
||||
|
||||
@@ -140,19 +140,9 @@ async rewrites() {
|
||||
|
||||
## 部署
|
||||
|
||||
### 构建 Docker 镜像
|
||||
本仓库作为后端仓库(`juwan-backend`)的子模块接入 dev compose,通过其中的 Envoy 网关同源对外提供。
|
||||
|
||||
```bash
|
||||
docker build -t juwan-frontend:latest .
|
||||
docker tag juwan-frontend:latest 103.236.53.208:4418/library/juwan-frontend:latest
|
||||
docker push 103.236.53.208:4418/library/juwan-frontend:latest
|
||||
```
|
||||
|
||||
### 部署到 Kubernetes
|
||||
|
||||
```bash
|
||||
kubectl apply -f deploy/k8s/frontend.yaml
|
||||
```
|
||||
`Dockerfile` 使用 Next.js standalone 输出。后端 `deploy/dev/build.py` 会扫描出 `frontend` target 一并构建为 `juwan/frontend:dev`,随 compose 拉起,浏览器从 `http://localhost:18080` 访问。
|
||||
|
||||
## 开发规范
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { EmptyState } from "@/components/ui/empty-state"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { StatusBadge, type StatusBadgeProps } from "@/components/ui/status-badge"
|
||||
import { listOrders, listPlayers, listServices, listShops } from "@/lib/api"
|
||||
import { getMyPlayer, getMyShop, getShopIncomeStats, listOrders } from "@/lib/api"
|
||||
import { statusLabels } from "@/lib/constants"
|
||||
import type { Player, PlayerService, Shop } from "@/lib/types"
|
||||
import type { Player, Shop } from "@/lib/types"
|
||||
import { useAuthStore } from "@/store/auth"
|
||||
import { CheckCircle, Clock, DollarSign, ListOrdered, Star, TrendingUp, Users } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
@@ -38,25 +38,29 @@ export default function DashboardPage() {
|
||||
|
||||
const [player, setPlayer] = useState<Player | null>(null)
|
||||
const [shop, setShop] = useState<Shop | null>(null)
|
||||
const [services, setServices] = useState<PlayerService[]>([])
|
||||
const [orders, setOrders] = useState<Awaited<ReturnType<typeof listOrders>>>([])
|
||||
const [monthlyIncome, setMonthlyIncome] = useState<string>("0")
|
||||
const recentOrders = orders.slice(0, 3)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) {
|
||||
setPlayer(null)
|
||||
setShop(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
Promise.all([listPlayers(), listShops(), listServices()])
|
||||
.then(([players, shops, services]) => {
|
||||
Promise.all([getMyPlayer(), getMyShop()])
|
||||
.then(([player, shop]) => {
|
||||
if (cancelled) return
|
||||
setPlayer(players.find((item) => item.user.id === user?.id) ?? null)
|
||||
setShop(shops.find((item) => item.owner.id === user?.id) ?? null)
|
||||
setServices(services)
|
||||
setPlayer(player ?? null)
|
||||
setShop(shop ?? null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setPlayer(null)
|
||||
setShop(null)
|
||||
setServices([])
|
||||
})
|
||||
|
||||
return () => {
|
||||
@@ -88,13 +92,34 @@ export default function DashboardPage() {
|
||||
}
|
||||
}, [orderRole])
|
||||
|
||||
useEffect(() => {
|
||||
if (!shop) {
|
||||
setMonthlyIncome("0")
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
getShopIncomeStats(shop.id)
|
||||
.then((stats) => {
|
||||
if (cancelled) return
|
||||
setMonthlyIncome(stats.monthlyIncome)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setMonthlyIncome("0")
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [shop])
|
||||
|
||||
const totalOrders = isOwner ? (shop?.totalOrders ?? 0) : (player?.totalOrders ?? 0)
|
||||
const rating = isOwner ? (shop?.rating ?? 0) : (player?.rating ?? 0)
|
||||
const playerCount = shop?.playerCount ?? 0
|
||||
const completionRate = player?.completionRate ?? 0
|
||||
const serviceCount = player
|
||||
? services.filter((service) => String(service.playerId) === String(player.id)).length
|
||||
: 0
|
||||
const serviceCount = player?.services.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-6xl px-4 py-8 space-y-8">
|
||||
@@ -149,7 +174,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{isOwner ? "——" : serviceCount}</div>
|
||||
<div className="text-2xl font-bold">{isOwner ? `¥${monthlyIncome}` : serviceCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { EmptyState } from "@/components/ui/empty-state"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { getShopById, listPlayersByShop, listReviews, listServices } from "@/lib/api"
|
||||
import { getShopById, listPlayersByShop, listReviews } from "@/lib/api"
|
||||
import { getShopSections } from "@/lib/domain/shop-template"
|
||||
import { Gamepad2, Megaphone, ShoppingBag, Star, Users } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
@@ -24,9 +24,8 @@ export default async function ShopPage({ params }: PageProps) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const [shopPlayers, allServices] = await Promise.all([listPlayersByShop(shop.id), listServices()])
|
||||
const playerIds = new Set(shopPlayers.map((player) => String(player.id)))
|
||||
const shopServices = allServices.filter((service) => playerIds.has(String(service.playerId)))
|
||||
const shopPlayers = await listPlayersByShop(shop.id)
|
||||
const shopServices = shopPlayers.flatMap((player) => player.services)
|
||||
const shopReviews = await listReviews()
|
||||
const sortedSections = getShopSections(shop).filter((s) => s.enabled)
|
||||
|
||||
|
||||
+69
-117
@@ -1,14 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { EmptyState } from "@/components/ui/empty-state"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { getChatSessionById, listChatMessages } from "@/lib/api"
|
||||
import { sendImageMessage, sendTextMessage } from "@/lib/api/chat"
|
||||
import { uploadFile } from "@/lib/api"
|
||||
import { useChatSocket } from "@/lib/hooks/use-chat-socket"
|
||||
import { notifyInfo } from "@/lib/toast"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuthStore } from "@/store/auth"
|
||||
@@ -18,61 +16,46 @@ import Link from "next/link"
|
||||
import { use, useEffect, useRef, useState } from "react"
|
||||
|
||||
export default function ChatDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [session, setSession] = useState<
|
||||
Awaited<ReturnType<typeof getChatSessionById>> | undefined
|
||||
>(undefined)
|
||||
const [messages, setMessages] = useState<Awaited<ReturnType<typeof listChatMessages>>>([])
|
||||
const { id: targetUserId } = use(params)
|
||||
const [input, setInput] = useState("")
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const { user } = useAuthStore()
|
||||
const {
|
||||
connected,
|
||||
sessionId,
|
||||
messages,
|
||||
error,
|
||||
createDM,
|
||||
joinSession,
|
||||
leaveSession,
|
||||
sendTextMessage,
|
||||
sendImageMessage,
|
||||
} = useChatSocket()
|
||||
|
||||
const cacheKey = user?.id ? `chat:dm:${user.id}:${targetUserId}` : null
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void Promise.all([
|
||||
Promise.resolve(getChatSessionById(id)),
|
||||
Promise.resolve(listChatMessages(id)),
|
||||
])
|
||||
.then(([nextSession, nextMessages]) => {
|
||||
if (cancelled) return
|
||||
setSession(nextSession)
|
||||
setMessages(nextMessages)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setSession(undefined)
|
||||
setMessages([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (!connected || !cacheKey) return
|
||||
const cached = window.localStorage.getItem(cacheKey)
|
||||
if (cached) {
|
||||
joinSession(cached)
|
||||
} else {
|
||||
createDM(targetUserId)
|
||||
}
|
||||
}, [id])
|
||||
}, [connected, cacheKey, createDM, joinSession, targetUserId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8">
|
||||
<EmptyState title="加载中" description="正在读取会话内容..." icon={MessageSquare} />
|
||||
</div>
|
||||
useEffect(() => {
|
||||
if (!sessionId || !cacheKey) return
|
||||
window.localStorage.setItem(cacheKey, sessionId)
|
||||
}, [sessionId, cacheKey])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (sessionId) leaveSession(sessionId)
|
||||
},
|
||||
[leaveSession, sessionId],
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8">
|
||||
<EmptyState
|
||||
title="会话不存在"
|
||||
description="该会话可能已被删除或暂不可访问。"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user?.id) {
|
||||
return (
|
||||
@@ -86,68 +69,47 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
||||
)
|
||||
}
|
||||
|
||||
const userId = user.id
|
||||
const isParticipant = session.participants.some((participant) => participant.id === userId)
|
||||
if (!isParticipant) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8">
|
||||
<EmptyState
|
||||
title="无法查看会话"
|
||||
description="仅会话参与方可查看并发送消息。"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const other = session.participants.find((p) => p.id !== userId) ?? session.participants[0]
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8 h-[calc(100vh-3.5rem)] flex flex-col">
|
||||
<Card className="flex-1 flex flex-col overflow-hidden border-border/80 shadow-sm">
|
||||
<div className="border-b border-border/60 px-4 py-3 flex items-center gap-3 bg-background">
|
||||
<div className="container mx-auto flex h-[calc(100vh-3.5rem)] max-w-2xl flex-col px-4 py-8">
|
||||
<Card className="flex flex-1 flex-col overflow-hidden border-border/80 shadow-sm">
|
||||
<div className="flex items-center gap-3 border-b border-border/60 bg-background px-4 py-3">
|
||||
<Link href="/chat" className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={other.avatar} />
|
||||
<AvatarFallback>{other.nickname[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{other.nickname}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant={session.type === "order" ? "info" : "neutral"}
|
||||
className="text-[10px] px-1.5 py-0 font-normal"
|
||||
>
|
||||
{session.type === "order" ? "订单会话" : "咨询会话"}
|
||||
</Badge>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">会话 {targetUserId}</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{connected ? (sessionId ? "已连接" : "正在创建会话") : "连接中"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
{error && <p className="text-center text-xs text-destructive">{error}</p>}
|
||||
{messages.length === 0 && (
|
||||
<EmptyState
|
||||
title="暂无消息"
|
||||
description="发送第一条消息开始沟通。"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
)}
|
||||
{messages.map((msg) => {
|
||||
if (msg.type === "system") {
|
||||
return (
|
||||
<div key={msg.id} className="text-center">
|
||||
<span className="text-xs text-muted-foreground bg-muted/60 px-2 py-1 rounded-full">
|
||||
<span className="rounded-full bg-muted/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isMine = msg.senderId === userId
|
||||
const sender = session.participants.find(
|
||||
(participant) => participant.id === msg.senderId,
|
||||
)
|
||||
const isMine = msg.senderId === user.id
|
||||
return (
|
||||
<div key={msg.id} className={cn("flex gap-2", isMine && "flex-row-reverse")}>
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarImage src={sender?.avatar} />
|
||||
<AvatarFallback>{(sender?.nickname ?? "?")[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className={cn("max-w-[70%]", isMine && "text-right")}>
|
||||
{msg.type === "image" ? (
|
||||
<Image
|
||||
@@ -156,7 +118,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
||||
width={256}
|
||||
height={192}
|
||||
unoptimized
|
||||
className="inline-block rounded-lg max-h-48 max-w-64 border"
|
||||
className="inline-block max-h-48 max-w-64 rounded-lg border"
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
@@ -170,7 +132,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
||||
{msg.content}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
{new Date(msg.createdAt).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -183,7 +145,7 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t border-border/60 p-4 bg-background">
|
||||
<div className="border-t border-border/60 bg-background p-4">
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
@@ -193,41 +155,30 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
||||
const target = event.currentTarget
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
const imageUrl = URL.createObjectURL(file)
|
||||
|
||||
void Promise.resolve(sendImageMessage(session.id, imageUrl))
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
notifyInfo(result.error.msg)
|
||||
return
|
||||
}
|
||||
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
|
||||
})
|
||||
setUploading(true)
|
||||
void uploadFile(file, "chat")
|
||||
.then((imageUrl) => sendImageMessage(imageUrl))
|
||||
.catch(() => notifyInfo("图片发送失败"))
|
||||
.finally(() => {
|
||||
setUploading(false)
|
||||
target.value = ""
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const text = input.trim()
|
||||
if (!text) return
|
||||
|
||||
void Promise.resolve(sendTextMessage(session.id, text)).then((result) => {
|
||||
if (!result.ok) {
|
||||
notifyInfo(result.error.msg)
|
||||
return
|
||||
}
|
||||
if (!text || !sessionId) return
|
||||
sendTextMessage(text)
|
||||
setInput("")
|
||||
return Promise.resolve(listChatMessages(session.id)).then(setMessages)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder="输入消息..."
|
||||
className="flex-1 border-border/60"
|
||||
/>
|
||||
@@ -235,11 +186,12 @@ export default function ChatDetailPage({ params }: { params: Promise<{ id: strin
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
disabled={!sessionId || uploading}
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="submit" size="icon" disabled={!input.trim()}>
|
||||
<Button type="submit" size="icon" disabled={!sessionId || !input.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
+70
-40
@@ -1,84 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { EmptyState } from "@/components/ui/empty-state"
|
||||
import { listChatSessions } from "@/lib/api"
|
||||
import { getPlayerById, listOrders } from "@/lib/api"
|
||||
import { isActiveOrder } from "@/lib/domain/order-filters"
|
||||
import type { UserRole } from "@/lib/types"
|
||||
import { useAuthStore } from "@/store/auth"
|
||||
import { MessageSquare } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type ChatEntry = {
|
||||
orderId: string
|
||||
targetUserId: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function orderRole(role: UserRole): "consumer" | "player" | undefined {
|
||||
if (role === "consumer" || role === "player") return role
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default function ChatListPage() {
|
||||
const [sessions, setSessions] = useState<Awaited<ReturnType<typeof listChatSessions>>>([])
|
||||
const userId = useAuthStore((state) => state.user?.id)
|
||||
const currentRole = useAuthStore((state) => state.currentRole)
|
||||
const role = orderRole(currentRole)
|
||||
const [entries, setEntries] = useState<ChatEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
if (!role) {
|
||||
setEntries([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await Promise.resolve(listChatSessions())
|
||||
if (!cancelled) setSessions(result)
|
||||
const orders = (await listOrders({ role })).filter((order) => isActiveOrder(order.status))
|
||||
const nextEntries = await Promise.all(
|
||||
orders.map(async (order) => {
|
||||
if (role === "consumer") {
|
||||
const player = await getPlayerById(String(order.playerId))
|
||||
if (!player) return null
|
||||
return {
|
||||
orderId: String(order.id),
|
||||
targetUserId: player.user.id,
|
||||
title: player.user.nickname,
|
||||
description: order.service.title,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
orderId: String(order.id),
|
||||
targetUserId: String(order.consumerId),
|
||||
title: `客户 ${order.consumerId}`,
|
||||
description: order.service.title,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!cancelled)
|
||||
setEntries(nextEntries.filter((entry): entry is ChatEntry => entry !== null))
|
||||
} catch {
|
||||
if (!cancelled) setSessions([])
|
||||
if (!cancelled) setEntries([])
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [role])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
|
||||
<div className="container mx-auto max-w-2xl space-y-6 px-4 py-8">
|
||||
<h1 className="text-2xl font-bold">消息</h1>
|
||||
|
||||
{sessions.length > 0 ? (
|
||||
{entries.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm">
|
||||
{sessions.map((session) => {
|
||||
const other =
|
||||
session.participants.find((participant) => participant.id !== userId) ??
|
||||
session.participants[0]
|
||||
return (
|
||||
{entries.map((entry) => (
|
||||
<Link
|
||||
key={session.id}
|
||||
href={`/chat/${session.id}`}
|
||||
key={entry.orderId}
|
||||
href={`/chat/${entry.targetUserId}?orderId=${entry.orderId}`}
|
||||
className="block border-b border-border/60 transition-colors last:border-0 hover:bg-muted/10"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={other.avatar} />
|
||||
<AvatarFallback>{other.nickname[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{other.nickname}</span>
|
||||
<Badge
|
||||
variant={session.type === "order" ? "info" : "neutral"}
|
||||
className="text-[10px] px-1.5 py-0 font-normal"
|
||||
>
|
||||
{session.type === "order" ? "订单" : "咨询"}
|
||||
<span className="text-sm font-medium">{entry.title}</span>
|
||||
<Badge variant="info" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
订单
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{session.lastMessage}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{session.unreadCount > 0 && (
|
||||
<Badge className="h-4 min-w-4 px-1 flex items-center justify-center rounded-full text-[10px]">
|
||||
{session.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
<p className="truncate text-xs text-muted-foreground">{entry.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无消息"
|
||||
description="订单沟通和咨询会话会显示在这里。"
|
||||
title={loading ? "消息加载中" : "暂无消息"}
|
||||
description="进行中的订单沟通会显示在这里。"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -44,15 +44,7 @@ const disputeStatusVariants: Record<string, StatusBadgeProps["status"]> = {
|
||||
appealed: "info",
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
function deriveMinimalTimeline(dispute: { id: string; status: string; createdAt: string }) {
|
||||
const steps = [
|
||||
{ status: "open", content: "争议已提交" },
|
||||
{ status: "reviewing", content: "平台审核中" },
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { deny } from "@/lib/decision"
|
||||
import type { ApiDecision } from "@/lib/errors"
|
||||
import type { ChatMessage, ChatSession } from "@/lib/types"
|
||||
|
||||
export type ListChatSessionsOptions = {
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type ListChatMessagesOptions = {
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
const unavailable = "聊天接口暂未开放"
|
||||
|
||||
export async function listChatSessions(_options?: ListChatSessionsOptions): Promise<ChatSession[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
export async function getChatSessionById(_sessionId: string): Promise<ChatSession | undefined> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function listChatMessages(
|
||||
_sessionId: string,
|
||||
_options?: ListChatMessagesOptions,
|
||||
): Promise<ChatMessage[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
export async function sendTextMessage(_sessionId: string, _content: string): Promise<ApiDecision> {
|
||||
return deny(404, unavailable)
|
||||
}
|
||||
|
||||
export async function sendImageMessage(
|
||||
_sessionId: string,
|
||||
_imageUrl: string,
|
||||
): Promise<ApiDecision> {
|
||||
return deny(404, unavailable)
|
||||
}
|
||||
+9
-60
@@ -4,20 +4,6 @@ import type { Dispute } from "@/lib/types"
|
||||
|
||||
import { httpJson } from "./http"
|
||||
|
||||
export type DisputeTimelineItem = {
|
||||
id: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type DisputeRecord = Dispute & {
|
||||
respondentReason?: string
|
||||
respondentEvidence: string[]
|
||||
appealReason?: string
|
||||
appealedAt?: string
|
||||
timeline: DisputeTimelineItem[]
|
||||
}
|
||||
|
||||
export type ListDisputesOptions = {
|
||||
offset?: number
|
||||
limit?: number
|
||||
@@ -64,49 +50,12 @@ function unwrapDispute(value: unknown): unknown {
|
||||
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,
|
||||
})
|
||||
|
||||
function normalizeDispute(value: unknown): Dispute {
|
||||
const dispute = unwrapDispute(value) as Dispute
|
||||
return {
|
||||
...dispute,
|
||||
evidence,
|
||||
respondentEvidence,
|
||||
timeline,
|
||||
evidence: Array.isArray(dispute.evidence) ? dispute.evidence : [],
|
||||
respondentEvidence: Array.isArray(dispute.respondentEvidence) ? dispute.respondentEvidence : [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,20 +67,20 @@ function denyFromError(error: unknown): ApiDecision {
|
||||
return deny(apiError.code, apiError.msg)
|
||||
}
|
||||
|
||||
export async function listDisputes(options?: ListDisputesOptions): Promise<DisputeRecord[]> {
|
||||
const res = await httpJson<Paginated<DisputeRecord> | DisputeRecord[]>(
|
||||
export async function listDisputes(options?: ListDisputesOptions): Promise<Dispute[]> {
|
||||
const res = await httpJson<Paginated<Dispute> | Dispute[]>(
|
||||
withOffsetLimit("/api/v1/disputes", options),
|
||||
{ cache: "no-store" },
|
||||
)
|
||||
return unwrapItems<DisputeRecord>(res).map((item) => normalizeDisputeRecord(item))
|
||||
return unwrapItems<Dispute>(res).map((item) => normalizeDispute(item))
|
||||
}
|
||||
|
||||
export async function getDisputeByOrderId(orderId: string): Promise<DisputeRecord | undefined> {
|
||||
export async function getDisputeByOrderId(orderId: string): Promise<Dispute | undefined> {
|
||||
try {
|
||||
const res = await httpJson<unknown>(`/api/v1/orders/${encodeURIComponent(orderId)}/dispute`, {
|
||||
cache: "no-store",
|
||||
})
|
||||
return normalizeDisputeRecord(res)
|
||||
return normalizeDispute(res)
|
||||
} catch (error) {
|
||||
const apiError = isApiError(error) ? error : toApiError(error)
|
||||
if (apiError.code === 404) return undefined
|
||||
|
||||
@@ -12,6 +12,29 @@ function getCookieValue(name: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
let csrfReady: Promise<void> | null = null
|
||||
|
||||
async function prepareCsrf() {
|
||||
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
|
||||
|
||||
csrfReady ??= fetch("/healthz", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
.then(() => {})
|
||||
.finally(() => {
|
||||
csrfReady = null
|
||||
})
|
||||
await csrfReady
|
||||
|
||||
if (!getCookieValue("__Host-XSRF-TOKEN")) {
|
||||
await fetch("/api/v1/games?offset=0&limit=1", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonBody(res: Response): Promise<{ json: unknown | null; text: string }> {
|
||||
const text = await res.text()
|
||||
if (!text) return { json: null, text: "" }
|
||||
@@ -70,6 +93,8 @@ export async function uploadFile(file: File, type: UploadFileType): Promise<stri
|
||||
formData.set("type", type)
|
||||
formData.set("file", file)
|
||||
|
||||
await prepareCsrf()
|
||||
|
||||
const headers = new Headers()
|
||||
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
|
||||
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
|
||||
@@ -78,6 +103,7 @@ export async function uploadFile(file: File, type: UploadFileType): Promise<stri
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
const { json, text } = await readJsonBody(res)
|
||||
|
||||
+31
-4
@@ -60,6 +60,34 @@ function getCookieValue(name: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
let csrfReady: Promise<void> | null = null
|
||||
|
||||
function needsCsrf(method: string) {
|
||||
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE"
|
||||
}
|
||||
|
||||
async function prepareCsrf(path: string) {
|
||||
if (typeof document === "undefined") return
|
||||
if (getCookieValue("__Host-XSRF-TOKEN") && getCookieValue("__Host-XSRF-GUARD")) return
|
||||
|
||||
csrfReady ??= fetch("/healthz", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
.then(() => {})
|
||||
.finally(() => {
|
||||
csrfReady = null
|
||||
})
|
||||
await csrfReady
|
||||
|
||||
if (!getCookieValue("__Host-XSRF-TOKEN") && path !== "/healthz") {
|
||||
await fetch("/api/v1/games?offset=0&limit=1", {
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isApiErrorWithMessage(value: unknown): value is { code: number; message: string } {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const v = value as { code?: unknown; message?: unknown }
|
||||
@@ -91,10 +119,8 @@ export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise
|
||||
}
|
||||
|
||||
const method = (rest.method ?? "GET").toUpperCase()
|
||||
if (
|
||||
(method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") &&
|
||||
!headers.has("XSRF-TOKEN")
|
||||
) {
|
||||
if (needsCsrf(method) && !headers.has("XSRF-TOKEN")) {
|
||||
await prepareCsrf(path)
|
||||
const xsrfToken = getCookieValue("__Host-XSRF-TOKEN")
|
||||
if (xsrfToken) headers.set("XSRF-TOKEN", xsrfToken)
|
||||
}
|
||||
@@ -103,6 +129,7 @@ export async function httpJson<T>(path: string, init?: JsonRequestInit): Promise
|
||||
...rest,
|
||||
headers,
|
||||
body,
|
||||
credentials: rest.credentials ?? "include",
|
||||
})
|
||||
|
||||
const { json: data, text } = await readJsonBody(res)
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
export { login, logout, register, resetPassword } from "./auth"
|
||||
export { sendForgotPasswordCode } from "./auth-extra"
|
||||
export { getChatSessionById, listChatMessages, listChatSessions } from "./chat"
|
||||
export { requestWithAuth } from "./client"
|
||||
export { addComment, listCommentsByPost, toggleCommentLike } from "./comments"
|
||||
export { getDisputeByOrderId, listDisputes } from "./disputes"
|
||||
@@ -14,7 +13,7 @@ export {
|
||||
markNotificationAsRead,
|
||||
} from "./notifications"
|
||||
export { getOrderById, listOrders } from "./orders"
|
||||
export { getPlayerById, listPlayers, listPlayersByShop } from "./players"
|
||||
export { getMyPlayer, getPlayerById, listPlayers, listPlayersByShop } from "./players"
|
||||
export { createPost, getPostById, listPosts, listPostsByAuthor, togglePostLike } from "./posts"
|
||||
export { listReviews, listReviewsByOrder, listReviewsByTargetUser } from "./reviews"
|
||||
export {
|
||||
|
||||
+6
-6
@@ -80,18 +80,18 @@ export async function getOrderById(orderId: string): Promise<Order | undefined>
|
||||
}
|
||||
|
||||
interface CreatePaidOrderInput {
|
||||
playerId: string | number
|
||||
serviceId: string | number
|
||||
shopId?: string | number
|
||||
playerId: string
|
||||
serviceId: string
|
||||
shopId?: string
|
||||
quantity: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
function createOrderJson(input: CreatePaidOrderInput) {
|
||||
return {
|
||||
playerId: Number(input.playerId),
|
||||
serviceId: Number(input.serviceId),
|
||||
...(input.shopId ? { shopId: Number(input.shopId) } : {}),
|
||||
playerId: input.playerId,
|
||||
serviceId: input.serviceId,
|
||||
...(input.shopId ? { shopId: input.shopId } : {}),
|
||||
quantity: input.quantity,
|
||||
...(input.note ? { note: input.note } : {}),
|
||||
}
|
||||
|
||||
@@ -35,6 +35,20 @@ export async function getPlayerById(playerId: string): Promise<Player | undefine
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMyPlayer(): Promise<Player | undefined> {
|
||||
try {
|
||||
return await httpJson<Player>("/api/v1/players/me", { cache: "no-store" })
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "UNAUTHORIZED") {
|
||||
throw error
|
||||
}
|
||||
if (isApiError(error) && error.code === 404) {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlayersByShop(shopId: string): Promise<Player[]> {
|
||||
const players = await listPlayers()
|
||||
return players.filter((player) => String(player.shopId) === shopId)
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export type ServiceInput = {
|
||||
|
||||
function serviceJson(input: ServiceInput) {
|
||||
return {
|
||||
gameId: Number(input.gameId),
|
||||
gameId: input.gameId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
price: input.price,
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ export async function inviteShopPlayer(shopId: string, playerId: string): Promis
|
||||
await httpJson<unknown>(`/api/v1/shops/${encodeURIComponent(shopId)}/invitations`, {
|
||||
method: "POST",
|
||||
json: {
|
||||
playerId: Number(playerId),
|
||||
playerId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client"
|
||||
|
||||
import { useAuthStore } from "@/store/auth"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
export type ChatSocketMessage = {
|
||||
id: string
|
||||
sessionId: string
|
||||
senderId: string
|
||||
type: "text" | "image" | "system"
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type WsEnvelope = {
|
||||
type: string
|
||||
sessionId?: number
|
||||
senderId?: number
|
||||
content?: string
|
||||
msgType?: string
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
type ChatSocketState = {
|
||||
connected: boolean
|
||||
sessionId: string | null
|
||||
messages: ChatSocketMessage[]
|
||||
error: string | null
|
||||
createDM: (targetUserId: string) => void
|
||||
joinSession: (nextSessionId: string) => void
|
||||
leaveSession: (nextSessionId: string) => void
|
||||
sendTextMessage: (content: string) => void
|
||||
sendImageMessage: (imageUrl: string) => void
|
||||
requestHistory: (nextSessionId: string) => void
|
||||
}
|
||||
|
||||
function getChatSocketUrl() {
|
||||
const configured = process.env.NEXT_PUBLIC_BACKEND_URL
|
||||
const origin =
|
||||
configured ||
|
||||
(process.env.NODE_ENV === "development" ? "http://localhost:18080" : window.location.origin)
|
||||
const url = new URL(origin)
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
||||
url.pathname = "/ws/chat"
|
||||
url.search = ""
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function unixToIso(value: unknown) {
|
||||
if (typeof value !== "number" || value <= 0) return new Date().toISOString()
|
||||
return new Date(value * 1000).toISOString()
|
||||
}
|
||||
|
||||
function normalizeMessage(value: unknown): ChatSocketMessage | null {
|
||||
if (typeof value !== "object" || value === null) return null
|
||||
const item = value as {
|
||||
id?: unknown
|
||||
sessionId?: unknown
|
||||
senderId?: unknown
|
||||
type?: unknown
|
||||
content?: unknown
|
||||
createdAt?: unknown
|
||||
}
|
||||
if (typeof item.content !== "string") return null
|
||||
|
||||
const type = item.type === "image" || item.type === "system" ? item.type : "text"
|
||||
return {
|
||||
id: String(item.id ?? `${item.sessionId ?? ""}-${item.senderId ?? ""}-${item.createdAt ?? ""}`),
|
||||
sessionId: String(item.sessionId ?? ""),
|
||||
senderId: String(item.senderId ?? ""),
|
||||
type,
|
||||
content: item.content,
|
||||
createdAt: unixToIso(item.createdAt),
|
||||
}
|
||||
}
|
||||
|
||||
function liveMessage(msg: WsEnvelope): ChatSocketMessage | null {
|
||||
if (msg.type !== "message" || !msg.content || msg.sessionId === undefined) return null
|
||||
const data = typeof msg.data === "object" && msg.data !== null ? msg.data : {}
|
||||
const messageId = "messageId" in data ? (data as { messageId?: unknown }).messageId : undefined
|
||||
const type = msg.msgType === "image" ? "image" : "text"
|
||||
|
||||
return {
|
||||
id: String(messageId ?? `${msg.sessionId}-${msg.senderId ?? ""}-${Date.now()}`),
|
||||
sessionId: String(msg.sessionId),
|
||||
senderId: String(msg.senderId ?? ""),
|
||||
type,
|
||||
content: msg.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function jsonWithInt64(type: string, fields: Record<string, string | undefined>) {
|
||||
const parts = [`"type":${JSON.stringify(type)}`]
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (!value) continue
|
||||
parts.push(`"${key}":${value}`)
|
||||
}
|
||||
return `{${parts.join(",")}}`
|
||||
}
|
||||
|
||||
function jsonMessage(sessionId: string, content: string, msgType: "text" | "image") {
|
||||
const payload = JSON.stringify({ type: "message", content, msgType })
|
||||
return payload.replace(/^\{/, `{"sessionId":${sessionId},`)
|
||||
}
|
||||
|
||||
export function useChatSocket(): ChatSocketState {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<ChatSocketMessage[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const userId = useAuthStore((state) => state.user?.id)
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return
|
||||
|
||||
const ws = new WebSocket(getChatSocketUrl())
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (wsRef.current !== ws) return
|
||||
setConnected(true)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data) as WsEnvelope
|
||||
|
||||
if (msg.type === "dm_created" || msg.type === "group_created" || msg.type === "joined") {
|
||||
if (msg.sessionId !== undefined) setSessionId(String(msg.sessionId))
|
||||
if (msg.sessionId !== undefined)
|
||||
ws.send(jsonWithInt64("history", { sessionId: String(msg.sessionId) }))
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "history") {
|
||||
const nextMessages = Array.isArray(msg.data)
|
||||
? msg.data
|
||||
.map(normalizeMessage)
|
||||
.filter((item): item is ChatSocketMessage => item !== null)
|
||||
: []
|
||||
setMessages(nextMessages)
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
setError(msg.content ?? "聊天服务错误")
|
||||
return
|
||||
}
|
||||
|
||||
const nextMessage = liveMessage(msg)
|
||||
if (nextMessage) setMessages((prev) => [...prev, nextMessage])
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (wsRef.current !== ws) return
|
||||
setConnected(false)
|
||||
wsRef.current = null
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (wsRef.current !== ws) return
|
||||
setError("聊天连接失败")
|
||||
ws.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
ws.close()
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const sendRaw = useCallback((payload: string) => {
|
||||
const ws = wsRef.current
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(payload)
|
||||
}, [])
|
||||
|
||||
const createDM = useCallback(
|
||||
(targetUserId: string) => sendRaw(jsonWithInt64("create_dm", { targetId: targetUserId })),
|
||||
[sendRaw],
|
||||
)
|
||||
|
||||
const joinSession = useCallback(
|
||||
(nextSessionId: string) => sendRaw(jsonWithInt64("join", { sessionId: nextSessionId })),
|
||||
[sendRaw],
|
||||
)
|
||||
|
||||
const leaveSession = useCallback(
|
||||
(nextSessionId: string) => sendRaw(jsonWithInt64("leave", { sessionId: nextSessionId })),
|
||||
[sendRaw],
|
||||
)
|
||||
|
||||
const requestHistory = useCallback(
|
||||
(nextSessionId: string) => sendRaw(jsonWithInt64("history", { sessionId: nextSessionId })),
|
||||
[sendRaw],
|
||||
)
|
||||
|
||||
const sendTextMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!sessionId) return
|
||||
sendRaw(jsonMessage(sessionId, content, "text"))
|
||||
},
|
||||
[sendRaw, sessionId],
|
||||
)
|
||||
|
||||
const sendImageMessage = useCallback(
|
||||
(imageUrl: string) => {
|
||||
if (!sessionId) return
|
||||
sendRaw(jsonMessage(sessionId, imageUrl, "image"))
|
||||
},
|
||||
[sendRaw, sessionId],
|
||||
)
|
||||
|
||||
return {
|
||||
connected,
|
||||
sessionId,
|
||||
messages,
|
||||
error,
|
||||
createDM,
|
||||
joinSession,
|
||||
leaveSession,
|
||||
sendTextMessage,
|
||||
sendImageMessage,
|
||||
requestHistory,
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types"
|
||||
import type { Player, PlayerService, Shop } from "@/lib/types"
|
||||
|
||||
export interface SearchCatalogParams {
|
||||
q?: string
|
||||
selectedGames?: string[]
|
||||
min?: string
|
||||
max?: string
|
||||
onlyOnline?: boolean
|
||||
minRating?: string
|
||||
sort?: SearchSort
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface SearchCatalogData {
|
||||
players: Player[]
|
||||
shops: Shop[]
|
||||
services: PlayerService[]
|
||||
}
|
||||
|
||||
type InternalResultItem = SearchResultItem & { __index: number }
|
||||
|
||||
function minPriceFromServices(services: PlayerService[]) {
|
||||
return services.length === 0 ? 0 : Math.min(...services.map((s) => s.price))
|
||||
}
|
||||
|
||||
function unitFromCheapestService(services: PlayerService[]): PlayerService["unit"] {
|
||||
if (services.length === 0) return "局"
|
||||
return services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
|
||||
}
|
||||
|
||||
export function searchCatalog(
|
||||
params: SearchCatalogParams,
|
||||
data: SearchCatalogData,
|
||||
): SearchResponse {
|
||||
const q = params.q ?? ""
|
||||
const query = q ? q.toLowerCase() : ""
|
||||
const selectedGames = params.selectedGames ?? []
|
||||
|
||||
const min = params.min ?? ""
|
||||
const max = params.max ?? ""
|
||||
const minP = min ? Number(min) : 0
|
||||
const maxP = max ? Number(max) : Infinity
|
||||
|
||||
const onlyOnline = params.onlyOnline ?? false
|
||||
const minRating = Number(params.minRating ?? "0")
|
||||
const sort = params.sort ?? "composite"
|
||||
|
||||
const offset = Math.max(0, params.offset ?? 0)
|
||||
const limit = Math.max(0, params.limit ?? 12)
|
||||
|
||||
const shopDerivedById = new Map<
|
||||
string,
|
||||
{
|
||||
minPrice: number
|
||||
unit: PlayerService["unit"]
|
||||
games: string[]
|
||||
hasAvailable: boolean
|
||||
}
|
||||
>()
|
||||
|
||||
for (const shop of data.shops) {
|
||||
const shopPlayers = data.players.filter((player) => player.shopId === shop.id)
|
||||
const playerIds = new Set(shopPlayers.map((player) => player.id))
|
||||
const shopServices = data.services.filter((service) => playerIds.has(service.playerId))
|
||||
const shopMinPrice = shopServices.length > 0 ? Math.min(...shopServices.map((s) => s.price)) : 0
|
||||
const shopUnit = shopServices.length > 0 ? unitFromCheapestService(shopServices) : "局"
|
||||
const shopGames = [...new Set(shopServices.map((service) => service.gameName))]
|
||||
const hasAvailable = shopPlayers.some((player) => player.status === "available")
|
||||
|
||||
shopDerivedById.set(shop.id, {
|
||||
minPrice: shopMinPrice,
|
||||
unit: shopUnit,
|
||||
games: shopGames,
|
||||
hasAvailable,
|
||||
})
|
||||
}
|
||||
|
||||
let nextIndex = 0
|
||||
const results: InternalResultItem[] = []
|
||||
|
||||
for (const player of data.players) {
|
||||
results.push({
|
||||
__index: nextIndex++,
|
||||
type: "player",
|
||||
player,
|
||||
rating: player.rating,
|
||||
orders: player.totalOrders,
|
||||
minPrice: minPriceFromServices(player.services),
|
||||
unit: unitFromCheapestService(player.services),
|
||||
})
|
||||
}
|
||||
|
||||
for (const shop of data.shops) {
|
||||
const derived = shopDerivedById.get(shop.id)
|
||||
if (!derived) continue
|
||||
|
||||
results.push({
|
||||
__index: nextIndex++,
|
||||
type: "shop",
|
||||
shop,
|
||||
rating: Number(shop.rating),
|
||||
orders: shop.totalOrders,
|
||||
minPrice: derived.minPrice,
|
||||
unit: derived.unit,
|
||||
games: derived.games,
|
||||
hasAvailable: derived.hasAvailable,
|
||||
})
|
||||
}
|
||||
|
||||
const filtered = results.filter((item) => {
|
||||
if (query) {
|
||||
if (item.type === "player") {
|
||||
const matchName = item.player.user.nickname.toLowerCase().includes(query)
|
||||
const matchTags = item.player.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||
const matchGames = item.player.games.some((game) => game.toLowerCase().includes(query))
|
||||
if (!matchName && !matchTags && !matchGames) return false
|
||||
} else {
|
||||
const matchName = item.shop.name.toLowerCase().includes(query)
|
||||
const matchDescription = item.shop.description.toLowerCase().includes(query)
|
||||
const matchGames = item.games.some((game) => game.toLowerCase().includes(query))
|
||||
if (!matchName && !matchDescription && !matchGames) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedGames.length > 0) {
|
||||
if (item.type === "player") {
|
||||
const hasGame = item.player.games.some((game) => selectedGames.includes(game))
|
||||
if (!hasGame) return false
|
||||
} else {
|
||||
const hasGame = item.games.some((game) => selectedGames.includes(game))
|
||||
if (!hasGame) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (item.minPrice < minP) return false
|
||||
if (max && item.minPrice > maxP) return false
|
||||
|
||||
if (onlyOnline) {
|
||||
if (item.type === "player") {
|
||||
if (item.player.status !== "available") return false
|
||||
} else {
|
||||
if (!item.hasAvailable) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === "player") {
|
||||
if (item.player.rating < minRating) return false
|
||||
} else {
|
||||
if (Number(item.shop.rating) < minRating) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
let diff = 0
|
||||
switch (sort) {
|
||||
case "rating":
|
||||
diff = b.rating - a.rating
|
||||
break
|
||||
case "price_asc":
|
||||
diff = a.minPrice - b.minPrice
|
||||
break
|
||||
case "price_desc":
|
||||
diff = b.minPrice - a.minPrice
|
||||
break
|
||||
case "orders":
|
||||
diff = b.orders - a.orders
|
||||
break
|
||||
case "composite": {
|
||||
const scoreA = a.rating * Math.log10(a.orders + 1)
|
||||
const scoreB = b.rating * Math.log10(b.orders + 1)
|
||||
diff = scoreB - scoreA
|
||||
break
|
||||
}
|
||||
default:
|
||||
diff = 0
|
||||
}
|
||||
|
||||
if (diff !== 0) return diff
|
||||
return a.__index - b.__index
|
||||
})
|
||||
|
||||
const total = filtered.length
|
||||
const items = filtered.slice(offset, offset + limit).map(({ __index, ...item }) => item)
|
||||
return { items, meta: { total, offset, limit } }
|
||||
}
|
||||
+11
-1
@@ -120,11 +120,21 @@ export interface Review {
|
||||
export interface Dispute {
|
||||
id: SnowflakeId
|
||||
orderId: SnowflakeId
|
||||
initiatorId?: SnowflakeId
|
||||
initiatorName?: string
|
||||
respondentId?: SnowflakeId
|
||||
reason: string
|
||||
evidence: string[]
|
||||
status: "open" | "reviewing" | "resolved" | "appealed"
|
||||
result?: "full_refund" | "full_payment" | "partial_refund"
|
||||
respondentReason?: string
|
||||
respondentEvidence: string[]
|
||||
appealReason?: string
|
||||
appealedAt?: string
|
||||
resolvedBy?: SnowflakeId
|
||||
resolvedAt?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ChatParticipant {
|
||||
@@ -168,7 +178,7 @@ export interface Post {
|
||||
content: string
|
||||
images: string[]
|
||||
tags: string[]
|
||||
linkedOrderId?: number
|
||||
linkedOrderId?: SnowflakeId
|
||||
pinned: boolean
|
||||
likeCount: number
|
||||
commentCount: number
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
// 仅在开发环境启用 API 代理
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
@@ -14,6 +15,10 @@ const nextConfig: NextConfig = {
|
||||
source: "/api/:path*",
|
||||
destination: `${backendUrl}/api/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/healthz",
|
||||
destination: `${backendUrl}/healthz`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
+4
-3
@@ -2,6 +2,7 @@
|
||||
"name": "juwan-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
@@ -19,7 +20,7 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"@tanstack/react-query": "^5.100.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -29,10 +30,10 @@
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.74.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^4.4.1",
|
||||
"zod": "^4.4.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+75
-75
@@ -10,13 +10,13 @@ importers:
|
||||
dependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.74.0(react@19.2.5))
|
||||
version: 5.2.2(react-hook-form@7.75.0(react@19.2.5))
|
||||
'@standard-schema/spec':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.100.6
|
||||
version: 5.100.6(react@19.2.5)
|
||||
specifier: ^5.100.8
|
||||
version: 5.100.8(react@19.2.5)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -45,8 +45,8 @@ importers:
|
||||
specifier: 19.2.5
|
||||
version: 19.2.5(react@19.2.5)
|
||||
react-hook-form:
|
||||
specifier: ^7.74.0
|
||||
version: 7.74.0(react@19.2.5)
|
||||
specifier: ^7.75.0
|
||||
version: 7.75.0(react@19.2.5)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -54,8 +54,8 @@ importers:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
zod:
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1
|
||||
specifier: ^4.4.2
|
||||
version: 4.4.2
|
||||
zustand:
|
||||
specifier: ^5.0.12
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
|
||||
@@ -122,8 +122,8 @@ packages:
|
||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/compat-data@7.29.0':
|
||||
resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==}
|
||||
'@babel/compat-data@7.29.3':
|
||||
resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/core@7.29.0':
|
||||
@@ -142,8 +142,8 @@ packages:
|
||||
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/helper-create-class-features-plugin@7.28.6':
|
||||
resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==}
|
||||
'@babel/helper-create-class-features-plugin@7.29.3':
|
||||
resolution: {integrity: sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0
|
||||
@@ -200,8 +200,8 @@ packages:
|
||||
resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
|
||||
'@babel/parser@7.29.3':
|
||||
resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -720,8 +720,8 @@ packages:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@mswjs/interceptors@0.41.7':
|
||||
resolution: {integrity: sha512-D0nkS5+sx87mYpxFqASImCineYoEl9wGlUPrzkuS0ohzG8wfophLpE+I76qGJ0slLAVI19do5SI9pWJNCVf4fg==}
|
||||
'@mswjs/interceptors@0.41.8':
|
||||
resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
@@ -1786,11 +1786,11 @@ packages:
|
||||
'@tailwindcss/postcss@4.2.4':
|
||||
resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==}
|
||||
|
||||
'@tanstack/query-core@5.100.6':
|
||||
resolution: {integrity: sha512-Os2CPUr98to98RYm+D4qGqGkiffn7MGSyl2547a4MljVkHE30AMJRqTiyCqBfMwzAx/I91vCkAxp5tHSla6Twg==}
|
||||
'@tanstack/query-core@5.100.8':
|
||||
resolution: {integrity: sha512-ceYwSFOqjPwET5TA6IOYxzxlGc0ekyH/gfOtWkP0PX43rzX9bxW48Iuw8KAduKCToi4rJAQ6nRy2kAe8gszdmg==}
|
||||
|
||||
'@tanstack/react-query@5.100.6':
|
||||
resolution: {integrity: sha512-uVSrps0PV16Cxmcn2rvL+dUhwTpTUtiRW347AEeYxMZXO2pZe9ja7E24PAMGoQ5u2g89DD8u4QhOviBk+RN8RA==}
|
||||
'@tanstack/react-query@5.100.8':
|
||||
resolution: {integrity: sha512-iNNEekixXU5vtAGKKZX2lx3jTooG5yNY+kv0wSgEdEYG0Mj0JM5bcuQtC35ZAP3nDopT6jciUK3xeX65U7AnfA==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
@@ -2147,8 +2147,8 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
baseline-browser-mapping@2.10.24:
|
||||
resolution: {integrity: sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==}
|
||||
baseline-browser-mapping@2.10.25:
|
||||
resolution: {integrity: sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2417,8 +2417,8 @@ packages:
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
electron-to-chromium@1.5.345:
|
||||
resolution: {integrity: sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==}
|
||||
electron-to-chromium@1.5.349:
|
||||
resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
@@ -3366,8 +3366,8 @@ packages:
|
||||
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
nanoid@3.3.11:
|
||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||
nanoid@3.3.12:
|
||||
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -3585,8 +3585,8 @@ packages:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postcss@8.5.12:
|
||||
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
|
||||
postcss@8.5.13:
|
||||
resolution: {integrity: sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
powershell-utils@0.1.0:
|
||||
@@ -3664,8 +3664,8 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.5
|
||||
|
||||
react-hook-form@7.74.0:
|
||||
resolution: {integrity: sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==}
|
||||
react-hook-form@7.75.0:
|
||||
resolution: {integrity: sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
@@ -4014,11 +4014,11 @@ packages:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tldts-core@7.0.29:
|
||||
resolution: {integrity: sha512-W99NuU7b1DcG3uJ3v9k9VztCH3WialNbBkBft5wCs8V8mexu0XQqaZEYb9l9RNNzK8+3EJ9PKWB0/RUtTQ/o+Q==}
|
||||
tldts-core@7.0.30:
|
||||
resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==}
|
||||
|
||||
tldts@7.0.29:
|
||||
resolution: {integrity: sha512-JIXCerhudr/N6OWLwLF1HVsTTUo7ry6qHa5eWZEkiMuxsIiAACL55tGLfqfHfoH7QaMQUW8fngD7u7TxWexYQg==}
|
||||
tldts@7.0.30:
|
||||
resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==}
|
||||
hasBin: true
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
@@ -4337,8 +4337,8 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.4.1:
|
||||
resolution: {integrity: sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q==}
|
||||
zod@4.4.2:
|
||||
resolution: {integrity: sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==}
|
||||
|
||||
zustand@5.0.12:
|
||||
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
||||
@@ -4368,7 +4368,7 @@ snapshots:
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/compat-data@7.29.0': {}
|
||||
'@babel/compat-data@7.29.3': {}
|
||||
|
||||
'@babel/core@7.29.0':
|
||||
dependencies:
|
||||
@@ -4377,7 +4377,7 @@ snapshots:
|
||||
'@babel/helper-compilation-targets': 7.28.6
|
||||
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/helpers': 7.29.2
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/traverse': 7.29.0
|
||||
'@babel/types': 7.29.0
|
||||
@@ -4392,7 +4392,7 @@ snapshots:
|
||||
|
||||
'@babel/generator@7.29.1':
|
||||
dependencies:
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
@@ -4404,13 +4404,13 @@ snapshots:
|
||||
|
||||
'@babel/helper-compilation-targets@7.28.6':
|
||||
dependencies:
|
||||
'@babel/compat-data': 7.29.0
|
||||
'@babel/compat-data': 7.29.3
|
||||
'@babel/helper-validator-option': 7.27.1
|
||||
browserslist: 4.28.2
|
||||
lru-cache: 5.1.1
|
||||
semver: 6.3.1
|
||||
|
||||
'@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)':
|
||||
'@babel/helper-create-class-features-plugin@7.29.3(@babel/core@7.29.0)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-annotate-as-pure': 7.27.3
|
||||
@@ -4481,7 +4481,7 @@ snapshots:
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/parser@7.29.2':
|
||||
'@babel/parser@7.29.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
@@ -4507,7 +4507,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/helper-annotate-as-pure': 7.27.3
|
||||
'@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/helper-create-class-features-plugin': 7.29.3(@babel/core@7.29.0)
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
|
||||
'@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0)
|
||||
@@ -4528,7 +4528,7 @@ snapshots:
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/types': 7.29.0
|
||||
|
||||
'@babel/traverse@7.29.0':
|
||||
@@ -4536,7 +4536,7 @@ snapshots:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/generator': 7.29.1
|
||||
'@babel/helper-globals': 7.28.0
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/template': 7.28.6
|
||||
'@babel/types': 7.29.0
|
||||
debug: 4.4.3
|
||||
@@ -4726,10 +4726,10 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.12.16
|
||||
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.74.0(react@19.2.5))':
|
||||
'@hookform/resolvers@5.2.2(react-hook-form@7.75.0(react@19.2.5))':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.74.0(react@19.2.5)
|
||||
react-hook-form: 7.75.0(react@19.2.5)
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
@@ -4912,7 +4912,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@mswjs/interceptors@0.41.7':
|
||||
'@mswjs/interceptors@0.41.8':
|
||||
dependencies:
|
||||
'@open-draft/deferred-promise': 2.2.0
|
||||
'@open-draft/logger': 0.3.0
|
||||
@@ -5909,14 +5909,14 @@ snapshots:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.2.4
|
||||
'@tailwindcss/oxide': 4.2.4
|
||||
postcss: 8.5.12
|
||||
postcss: 8.5.13
|
||||
tailwindcss: 4.2.4
|
||||
|
||||
'@tanstack/query-core@5.100.6': {}
|
||||
'@tanstack/query-core@5.100.8': {}
|
||||
|
||||
'@tanstack/react-query@5.100.6(react@19.2.5)':
|
||||
'@tanstack/react-query@5.100.8(react@19.2.5)':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.100.6
|
||||
'@tanstack/query-core': 5.100.8
|
||||
react: 19.2.5
|
||||
|
||||
'@ts-morph/common@0.27.0':
|
||||
@@ -6291,7 +6291,7 @@ snapshots:
|
||||
|
||||
balanced-match@4.0.4: {}
|
||||
|
||||
baseline-browser-mapping@2.10.24: {}
|
||||
baseline-browser-mapping@2.10.25: {}
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
@@ -6322,9 +6322,9 @@ snapshots:
|
||||
|
||||
browserslist@4.28.2:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.24
|
||||
baseline-browser-mapping: 2.10.25
|
||||
caniuse-lite: 1.0.30001791
|
||||
electron-to-chromium: 1.5.345
|
||||
electron-to-chromium: 1.5.349
|
||||
node-releases: 2.0.38
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||
|
||||
@@ -6534,7 +6534,7 @@ snapshots:
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
electron-to-chromium@1.5.345: {}
|
||||
electron-to-chromium@1.5.349: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
@@ -6802,11 +6802,11 @@ snapshots:
|
||||
eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
eslint: 9.39.4(jiti@2.6.1)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.4.1
|
||||
zod-validation-error: 4.0.2(zod@4.4.1)
|
||||
zod: 4.4.2
|
||||
zod-validation-error: 4.0.2(zod@4.4.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -7595,7 +7595,7 @@ snapshots:
|
||||
msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@inquirer/confirm': 6.0.12(@types/node@25.6.0)
|
||||
'@mswjs/interceptors': 0.41.7
|
||||
'@mswjs/interceptors': 0.41.8
|
||||
'@open-draft/deferred-promise': 3.0.0
|
||||
'@types/statuses': 2.0.6
|
||||
cookie: 1.1.1
|
||||
@@ -7619,7 +7619,7 @@ snapshots:
|
||||
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
@@ -7636,7 +7636,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@next/env': 16.2.4
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.10.24
|
||||
baseline-browser-mapping: 2.10.25
|
||||
caniuse-lite: 1.0.30001791
|
||||
postcss: 8.4.31
|
||||
react: 19.2.5
|
||||
@@ -7838,13 +7838,13 @@ snapshots:
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
nanoid: 3.3.12
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.12:
|
||||
postcss@8.5.13:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
nanoid: 3.3.12
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -7964,7 +7964,7 @@ snapshots:
|
||||
react: 19.2.5
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-hook-form@7.74.0(react@19.2.5):
|
||||
react-hook-form@7.75.0(react@19.2.5):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
||||
@@ -8181,7 +8181,7 @@ snapshots:
|
||||
shadcn@4.6.0(@types/node@25.6.0)(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/parser': 7.29.2
|
||||
'@babel/parser': 7.29.3
|
||||
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
|
||||
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.0)
|
||||
'@dotenvx/dotenvx': 1.64.0
|
||||
@@ -8203,7 +8203,7 @@ snapshots:
|
||||
node-fetch: 3.3.2
|
||||
open: 11.0.0
|
||||
ora: 8.2.0
|
||||
postcss: 8.5.12
|
||||
postcss: 8.5.13
|
||||
postcss-selector-parser: 7.1.1
|
||||
prompts: 2.4.2
|
||||
recast: 0.23.11
|
||||
@@ -8439,11 +8439,11 @@ snapshots:
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tldts-core@7.0.29: {}
|
||||
tldts-core@7.0.30: {}
|
||||
|
||||
tldts@7.0.29:
|
||||
tldts@7.0.30:
|
||||
dependencies:
|
||||
tldts-core: 7.0.29
|
||||
tldts-core: 7.0.30
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
@@ -8453,7 +8453,7 @@ snapshots:
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.29
|
||||
tldts: 7.0.30
|
||||
|
||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||
dependencies:
|
||||
@@ -8622,7 +8622,7 @@ snapshots:
|
||||
esbuild: 0.27.7
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.12
|
||||
postcss: 8.5.13
|
||||
rollup: 4.60.2
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
@@ -8757,13 +8757,13 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.4.1):
|
||||
zod-validation-error@4.0.2(zod@4.4.2):
|
||||
dependencies:
|
||||
zod: 4.4.1
|
||||
zod: 4.4.2
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.4.1: {}
|
||||
zod@4.4.2: {}
|
||||
|
||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)):
|
||||
optionalDependencies:
|
||||
|
||||
+8
-3
@@ -22,6 +22,7 @@ interface PersistedAuth {
|
||||
currentRole: UserRole
|
||||
verifiedRoles: UserRole[]
|
||||
verificationStatus: Partial<Record<UserRole, VerificationStatus>>
|
||||
notificationPrefs: NotificationPrefs
|
||||
themePreference: ThemePreference
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ function persistCurrent(state: AuthState) {
|
||||
currentRole: state.currentRole,
|
||||
verifiedRoles: state.verifiedRoles,
|
||||
verificationStatus: state.verificationStatus,
|
||||
notificationPrefs: state.notificationPrefs,
|
||||
themePreference: state.themePreference,
|
||||
})
|
||||
}
|
||||
@@ -95,13 +97,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
persistCurrent(get())
|
||||
}
|
||||
},
|
||||
setNotificationPref: (type, enabled) =>
|
||||
setNotificationPref: (type, enabled) => {
|
||||
set((state) => ({
|
||||
notificationPrefs: {
|
||||
...state.notificationPrefs,
|
||||
[type]: enabled,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
persistCurrent(get())
|
||||
},
|
||||
setThemePreference: (theme) => {
|
||||
set({ themePreference: theme })
|
||||
persistCurrent(get())
|
||||
@@ -138,6 +142,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
currentRole: user.role,
|
||||
verifiedRoles: nextVerifiedRoles,
|
||||
verificationStatus: nextVerificationStatus,
|
||||
notificationPrefs: state.notificationPrefs,
|
||||
themePreference: nextTheme,
|
||||
})
|
||||
|
||||
@@ -160,7 +165,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
verifiedRoles: ["consumer"],
|
||||
verificationStatus: { consumer: "approved" },
|
||||
verificationReasons: {},
|
||||
notificationPrefs: defaultNotificationPrefs,
|
||||
notificationPrefs: persisted?.notificationPrefs ?? defaultNotificationPrefs,
|
||||
themePreference: "system",
|
||||
user: null,
|
||||
})
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import type { SearchCatalogData } from "@/lib/search/search-catalog"
|
||||
import { searchCatalog } from "@/lib/search/search-catalog"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
const createdAt = "2025-01-01T00:00:00.000Z"
|
||||
|
||||
const players: SearchCatalogData["players"] = [
|
||||
{
|
||||
id: "1006",
|
||||
user: {
|
||||
id: "2006",
|
||||
username: "u1006",
|
||||
nickname: "Winter",
|
||||
avatar: "/avatars/u1006.png",
|
||||
role: "player",
|
||||
createdAt,
|
||||
},
|
||||
rating: 4.8,
|
||||
totalOrders: 120,
|
||||
completionRate: 0.98,
|
||||
status: "available",
|
||||
games: ["王者荣耀"],
|
||||
services: [
|
||||
{
|
||||
id: "s-1006-wzry",
|
||||
playerId: "1006",
|
||||
gameId: "g-wzry",
|
||||
gameName: "王者荣耀",
|
||||
title: "王者荣耀陪练",
|
||||
description: "",
|
||||
price: 30,
|
||||
unit: "局",
|
||||
availability: ["weekends"],
|
||||
},
|
||||
],
|
||||
shopId: "3002",
|
||||
gender: true,
|
||||
tags: ["moba"],
|
||||
},
|
||||
{
|
||||
id: "1007",
|
||||
user: {
|
||||
id: "2007",
|
||||
username: "u1007",
|
||||
nickname: "u7",
|
||||
avatar: "/avatars/u1007.png",
|
||||
role: "player",
|
||||
createdAt,
|
||||
},
|
||||
rating: 4.0,
|
||||
totalOrders: 20,
|
||||
completionRate: 0.9,
|
||||
status: "busy",
|
||||
games: ["英雄联盟"],
|
||||
services: [
|
||||
{
|
||||
id: "s-1007-lol",
|
||||
playerId: "1007",
|
||||
gameId: "g-lol",
|
||||
gameName: "英雄联盟",
|
||||
title: "英雄联盟陪练",
|
||||
description: "",
|
||||
price: 18,
|
||||
unit: "局",
|
||||
availability: ["weekdays"],
|
||||
},
|
||||
],
|
||||
shopId: "3003",
|
||||
gender: true,
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
id: "1008",
|
||||
user: {
|
||||
id: "2008",
|
||||
username: "u1008",
|
||||
nickname: "Ace",
|
||||
avatar: "/avatars/u1008.png",
|
||||
role: "player",
|
||||
createdAt,
|
||||
},
|
||||
rating: 4.7,
|
||||
totalOrders: 80,
|
||||
completionRate: 0.97,
|
||||
status: "available",
|
||||
games: ["CS2"],
|
||||
services: [
|
||||
{
|
||||
id: "s-1008-cs2",
|
||||
playerId: "1008",
|
||||
gameId: "g-cs2",
|
||||
gameName: "CS2",
|
||||
title: "CS2代练",
|
||||
description: "",
|
||||
price: 10,
|
||||
unit: "局",
|
||||
availability: ["weekends"],
|
||||
},
|
||||
],
|
||||
shopId: "3001",
|
||||
gender: false,
|
||||
tags: ["fps"],
|
||||
},
|
||||
{
|
||||
id: "1009",
|
||||
user: {
|
||||
id: "2009",
|
||||
username: "u1009",
|
||||
nickname: "u9",
|
||||
avatar: "/avatars/u1009.png",
|
||||
role: "player",
|
||||
createdAt,
|
||||
},
|
||||
rating: 3.5,
|
||||
totalOrders: 5,
|
||||
completionRate: 0.85,
|
||||
status: "offline",
|
||||
games: ["英雄联盟"],
|
||||
services: [
|
||||
{
|
||||
id: "s-1009-lol",
|
||||
playerId: "1009",
|
||||
gameId: "g-lol",
|
||||
gameName: "英雄联盟",
|
||||
title: "英雄联盟陪练",
|
||||
description: "",
|
||||
price: 12,
|
||||
unit: "局",
|
||||
availability: ["weekends"],
|
||||
},
|
||||
],
|
||||
shopId: "3003",
|
||||
gender: true,
|
||||
tags: [],
|
||||
},
|
||||
]
|
||||
|
||||
const shops: SearchCatalogData["shops"] = [
|
||||
{
|
||||
id: "3001",
|
||||
owner: {
|
||||
id: "4001",
|
||||
username: "owner3001",
|
||||
nickname: "Owner 3001",
|
||||
avatar: "/avatars/owner3001.png",
|
||||
role: "owner",
|
||||
createdAt,
|
||||
},
|
||||
name: "CS2 Hub",
|
||||
description: "",
|
||||
rating: "4.6",
|
||||
totalOrders: 300,
|
||||
playerCount: 1,
|
||||
commissionType: "fixed",
|
||||
commissionValue: "0",
|
||||
allowMultiShop: false,
|
||||
allowIndependentOrders: false,
|
||||
dispatchMode: "manual",
|
||||
announcements: [],
|
||||
templateConfig: { sections: [] },
|
||||
},
|
||||
{
|
||||
id: "3002",
|
||||
owner: {
|
||||
id: "4002",
|
||||
username: "owner3002",
|
||||
nickname: "Owner 3002",
|
||||
avatar: "/avatars/owner3002.png",
|
||||
role: "owner",
|
||||
createdAt,
|
||||
},
|
||||
name: "Yuki Studio",
|
||||
description: "",
|
||||
rating: "4.2",
|
||||
totalOrders: 50,
|
||||
playerCount: 1,
|
||||
commissionType: "fixed",
|
||||
commissionValue: "0",
|
||||
allowMultiShop: false,
|
||||
allowIndependentOrders: false,
|
||||
dispatchMode: "manual",
|
||||
announcements: [],
|
||||
templateConfig: { sections: [] },
|
||||
},
|
||||
{
|
||||
id: "3003",
|
||||
owner: {
|
||||
id: "4003",
|
||||
username: "owner3003",
|
||||
nickname: "Owner 3003",
|
||||
avatar: "/avatars/owner3003.png",
|
||||
role: "owner",
|
||||
createdAt,
|
||||
},
|
||||
name: "Quiet Shop",
|
||||
description: "",
|
||||
rating: "3.8",
|
||||
totalOrders: 10,
|
||||
playerCount: 2,
|
||||
commissionType: "fixed",
|
||||
commissionValue: "0",
|
||||
allowMultiShop: false,
|
||||
allowIndependentOrders: false,
|
||||
dispatchMode: "manual",
|
||||
announcements: [],
|
||||
templateConfig: { sections: [] },
|
||||
},
|
||||
]
|
||||
|
||||
const services: SearchCatalogData["services"] = players.flatMap((p) => p.services)
|
||||
|
||||
const data: SearchCatalogData = {
|
||||
players,
|
||||
shops,
|
||||
services,
|
||||
}
|
||||
|
||||
describe("searchCatalog", () => {
|
||||
describe("q matching (case-insensitive)", () => {
|
||||
it("matches player nickname", () => {
|
||||
const res = searchCatalog({ q: "winter", limit: 50 }, data)
|
||||
const playerItems = res.items.filter((i) => i.type === "player")
|
||||
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1006")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches player nickname case-insensitively", () => {
|
||||
const res = searchCatalog({ q: "WINTER", limit: 50 }, data)
|
||||
const playerItems = res.items.filter((i) => i.type === "player")
|
||||
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1006")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches shop name", () => {
|
||||
const res = searchCatalog({ q: "yuki", limit: 50 }, data)
|
||||
const shopItems = res.items.filter((i) => i.type === "shop")
|
||||
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3002")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches player game name", () => {
|
||||
const res = searchCatalog({ q: "cs2", limit: 50 }, data)
|
||||
const playerItems = res.items.filter((i) => i.type === "player")
|
||||
// 1008 has CS2 in games
|
||||
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1008")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches shop derived games", () => {
|
||||
const res = searchCatalog({ q: "cs2", limit: 50 }, data)
|
||||
const shopItems = res.items.filter((i) => i.type === "shop")
|
||||
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns all items when q is empty", () => {
|
||||
const res = searchCatalog({ limit: 50 }, data)
|
||||
expect(res.meta.total).toBe(players.length + shops.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe("selectedGames ANY-match", () => {
|
||||
it("filters players by game", () => {
|
||||
const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data)
|
||||
const playerItems = res.items.filter((i) => i.type === "player")
|
||||
expect(playerItems.every((i) => i.type === "player" && i.player.games.includes("CS2"))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(playerItems.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("filters shops by derived games from services", () => {
|
||||
const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data)
|
||||
const shopItems = res.items.filter((i) => i.type === "shop")
|
||||
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
|
||||
})
|
||||
|
||||
it("uses ANY-match (OR) for multiple games", () => {
|
||||
const res = searchCatalog({ selectedGames: ["CS2", "王者荣耀"], limit: 50 }, data)
|
||||
// Should include players with either game
|
||||
expect(res.meta.total).toBeGreaterThan(0)
|
||||
const playerItems = res.items.filter((i) => i.type === "player")
|
||||
for (const item of playerItems) {
|
||||
if (item.type === "player") {
|
||||
expect(item.player.games.includes("CS2") || item.player.games.includes("王者荣耀")).toBe(
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("onlyOnline semantics", () => {
|
||||
it("filters players by status === available", () => {
|
||||
const res = searchCatalog({ onlyOnline: true, limit: 50 }, data)
|
||||
const playerItems = res.items.filter((i) => i.type === "player")
|
||||
for (const item of playerItems) {
|
||||
if (item.type === "player") {
|
||||
expect(item.player.status).toBe("available")
|
||||
}
|
||||
}
|
||||
// u7 is busy, u9 is offline — they should be excluded
|
||||
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1007")).toBe(false)
|
||||
expect(playerItems.some((i) => i.type === "player" && i.player.id === "1009")).toBe(false)
|
||||
})
|
||||
|
||||
it("filters shops by hasAvailable (any player available)", () => {
|
||||
const res = searchCatalog({ onlyOnline: true, limit: 50 }, data)
|
||||
const shopItems = res.items.filter((i) => i.type === "shop")
|
||||
for (const item of shopItems) {
|
||||
if (item.type === "shop") {
|
||||
expect(item.hasAvailable).toBe(true)
|
||||
}
|
||||
}
|
||||
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3003")).toBe(false)
|
||||
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "3001")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("price min/max parsing", () => {
|
||||
it("filters by min price", () => {
|
||||
const res = searchCatalog({ min: "25", limit: 50 }, data)
|
||||
for (const item of res.items) {
|
||||
expect(item.minPrice).toBeGreaterThanOrEqual(25)
|
||||
}
|
||||
})
|
||||
|
||||
it("filters by max price", () => {
|
||||
const res = searchCatalog({ max: "20", limit: 50 }, data)
|
||||
for (const item of res.items) {
|
||||
expect(item.minPrice).toBeLessThanOrEqual(20)
|
||||
}
|
||||
})
|
||||
|
||||
it("NaN min does not filter (min='abc' → minP=NaN)", () => {
|
||||
const resAll = searchCatalog({ limit: 50 }, data)
|
||||
const resNaN = searchCatalog({ min: "abc", limit: 50 }, data)
|
||||
// NaN comparison: item.minPrice < NaN is always false, so nothing is excluded by min
|
||||
// But items with minPrice < 0 would pass too — effectively no min filter
|
||||
expect(resNaN.meta.total).toBe(resAll.meta.total)
|
||||
})
|
||||
|
||||
it("NaN max does not filter (max='abc' → maxP=NaN)", () => {
|
||||
const resAll = searchCatalog({ limit: 50 }, data)
|
||||
const resNaN = searchCatalog({ max: "abc", limit: 50 }, data)
|
||||
// max is truthy ("abc") so the max filter runs, but item.minPrice > NaN is always false
|
||||
expect(resNaN.meta.total).toBe(resAll.meta.total)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sort options", () => {
|
||||
it("sorts by rating descending", () => {
|
||||
const res = searchCatalog({ sort: "rating", limit: 50 }, data)
|
||||
for (let i = 1; i < res.items.length; i++) {
|
||||
expect(res.items[i - 1].rating).toBeGreaterThanOrEqual(res.items[i].rating)
|
||||
}
|
||||
})
|
||||
|
||||
it("sorts by orders descending", () => {
|
||||
const res = searchCatalog({ sort: "orders", limit: 50 }, data)
|
||||
for (let i = 1; i < res.items.length; i++) {
|
||||
expect(res.items[i - 1].orders).toBeGreaterThanOrEqual(res.items[i].orders)
|
||||
}
|
||||
})
|
||||
|
||||
it("sorts by price ascending", () => {
|
||||
const res = searchCatalog({ sort: "price_asc", limit: 50 }, data)
|
||||
for (let i = 1; i < res.items.length; i++) {
|
||||
expect(res.items[i - 1].minPrice).toBeLessThanOrEqual(res.items[i].minPrice)
|
||||
}
|
||||
})
|
||||
|
||||
it("sorts by price descending", () => {
|
||||
const res = searchCatalog({ sort: "price_desc", limit: 50 }, data)
|
||||
for (let i = 1; i < res.items.length; i++) {
|
||||
expect(res.items[i - 1].minPrice).toBeGreaterThanOrEqual(res.items[i].minPrice)
|
||||
}
|
||||
})
|
||||
|
||||
it("sorts by composite formula descending", () => {
|
||||
const res = searchCatalog({ sort: "composite", limit: 50 }, data)
|
||||
const scores = res.items.map((i) => i.rating * Math.log10(i.orders + 1))
|
||||
for (let i = 1; i < scores.length; i++) {
|
||||
expect(scores[i - 1]).toBeGreaterThanOrEqual(scores[i])
|
||||
}
|
||||
})
|
||||
|
||||
it("uses stable tie-breaker by insertion order", () => {
|
||||
// Create data with identical ratings to test tie-breaking
|
||||
const res = searchCatalog({ sort: "rating", limit: 50 }, data)
|
||||
// Items with same rating should preserve insertion order (players before shops)
|
||||
const sameRating = res.items.filter((i) => i.rating === res.items[0].rating)
|
||||
if (sameRating.length > 1) {
|
||||
// They should be in original insertion order
|
||||
expect(sameRating.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("pagination offset/limit", () => {
|
||||
it("returns correct meta", () => {
|
||||
const res = searchCatalog({ limit: 2, offset: 0 }, data)
|
||||
expect(res.meta.limit).toBe(2)
|
||||
expect(res.meta.offset).toBe(0)
|
||||
expect(res.items.length).toBe(2)
|
||||
expect(res.meta.total).toBe(players.length + shops.length)
|
||||
})
|
||||
|
||||
it("offset skips items", () => {
|
||||
const all = searchCatalog({ limit: 50 }, data)
|
||||
const page2 = searchCatalog({ limit: 2, offset: 2 }, data)
|
||||
expect(page2.items[0]).toEqual(all.items[2])
|
||||
expect(page2.items[1]).toEqual(all.items[3])
|
||||
})
|
||||
|
||||
it("offset beyond total returns empty items", () => {
|
||||
const res = searchCatalog({ limit: 10, offset: 100 }, data)
|
||||
expect(res.items.length).toBe(0)
|
||||
expect(res.meta.total).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,766 +0,0 @@
|
||||
# 聚玩 (Juwan) API 设计文档
|
||||
|
||||
## 1. 文档概述
|
||||
本文档为“聚玩”游戏陪玩平台的后端 API 设计规范,用于前后端联调与上线部署。
|
||||
- **基础路径 (Base URL)**: `/api/v1`
|
||||
- **数据格式**: `application/json`
|
||||
|
||||
## 2. 全局约定
|
||||
|
||||
### 2.1 认证与授权 (Auth)
|
||||
|
||||
- 采用 Cookie `JToken` 进行认证(Phase 2 接入后端时实现)。
|
||||
- 当前前端仍以本地数据实现为主,不涉及真实后端交互;接入后端后会删除本地数据实现。
|
||||
- 接口权限标识:
|
||||
- 🔒:需要登录认证。
|
||||
- 🛡️:需要管理员权限。
|
||||
|
||||
### 2.2 错误响应格式
|
||||
|
||||
所有失败的请求均返回统一的 JSON 结构,并附带 HTTP 状态码(如 400, 401, 403, 404, 500)。
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 401,
|
||||
"msg": "请先登录"
|
||||
}
|
||||
```
|
||||
|
||||
**常见错误码 (code)**:
|
||||
- `401`: 未登录或 Token 失效
|
||||
- `403`: 角色权限不足或非参与者
|
||||
- `404`: 资源不存在
|
||||
- `400`: 参数校验失败、状态不合法、重复请求等
|
||||
- `500`: 服务器内部错误
|
||||
|
||||
### 2.3 分页规范
|
||||
采用 `offset` 和 `limit` 进行分页(与前端 `SearchResponse` 保持一致)。
|
||||
**请求参数**:`?offset=0&limit=20`
|
||||
**响应格式**:
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"meta": {
|
||||
"total": 100,
|
||||
"offset": 0,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 文件上传与访问
|
||||
|
||||
- **上传端点**: `POST /api/v1/upload`
|
||||
- **Content-Type**: `multipart/form-data`
|
||||
- **参数**: `file` (文件), `type` (枚举: `avatar`, `chat`, `post`, `verification`, `dispute`)
|
||||
- **响应**: 返回文件的 CDN URL 或文件 ID。
|
||||
- **访问端点**: `GET /api/v1/files/:fileId` (获取文件内容)
|
||||
|
||||
### 2.5 时间戳格式
|
||||
所有时间字段均采用 ISO 8601 格式的 UTC 时间字符串,例如:`2024-03-20T12:00:00.000Z`。
|
||||
|
||||
### 2.6 角色系统 (UserRole)
|
||||
- `consumer`: 普通消费者(老板)
|
||||
- `player`: 陪玩打手
|
||||
- `owner`: 店铺店长
|
||||
- `admin`: 平台管理员(隐藏角色,用于后台管理)
|
||||
|
||||
### 2.7 ID 规范
|
||||
|
||||
所有 ID 均由后端使用 Snowflake 算法生成,类型为 `int64`。在前端交互和 JSON 序列化时,统一作为 `string` 处理,以避免精度丢失。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 User (用户)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------------ | ---------- | ---------------------------------------------------- |
|
||||
| id | string | 用户唯一标识 |
|
||||
| username | string | 登录用户名 |
|
||||
| email | string | 邮箱地址 (可选) |
|
||||
| nickname | string | 昵称 |
|
||||
| avatar | string | 头像 URL |
|
||||
| role | UserRole | 当前激活的角色 |
|
||||
| verifiedRoles | UserRole[] | 已认证的角色列表 |
|
||||
| verificationStatus | object | 各角色的认证状态 (`pending`, `approved`, `rejected`) |
|
||||
| bio | string | 个人简介 (可选) |
|
||||
| createdAt | string | 注册时间 |
|
||||
|
||||
### 3.2 Game (游戏)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| -------- | ------ | ----------------------------------- |
|
||||
| id | string | 游戏 ID |
|
||||
| name | string | 游戏名称 |
|
||||
| icon | string | 游戏图标 URL |
|
||||
| category | string | 分类 (`MOBA`, `FPS`, `动作`, `RPG`) |
|
||||
|
||||
### 3.3 Player & PlayerService (打手与服务)
|
||||
**PlayerService**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------ | -------- | --------------------------- |
|
||||
| id | string | 服务 ID |
|
||||
| playerId | string | 打手 ID |
|
||||
| gameId | string | 游戏 ID |
|
||||
| gameName | string | 游戏名称 |
|
||||
| title | string | 服务标题 |
|
||||
| description | string | 服务描述 |
|
||||
| price | number | 价格 |
|
||||
| unit | string | 计价单位 (`局`, `星`, `次`) |
|
||||
| rankRange | string | 段位范围 (可选) |
|
||||
| availability | string[] | 可接单时间段 |
|
||||
|
||||
**Player**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
| -------------- | --------------- | ------------------------------------- |
|
||||
| id | string | 打手 ID |
|
||||
| user | User | 关联的用户信息 |
|
||||
| rating | number | 综合评分 |
|
||||
| totalOrders | number | 总接单数 |
|
||||
| completionRate | number | 完单率 (0-1) |
|
||||
| status | string | 状态 (`available`, `busy`, `offline`) |
|
||||
| games | string[] | 擅长游戏 ID 列表 |
|
||||
| services | PlayerService[] | 提供的服务列表 |
|
||||
| shopId | string | 所属店铺 ID (可选) |
|
||||
| shopName | string | 所属店铺名称 (可选) |
|
||||
| tags | string[] | 个人标签 |
|
||||
|
||||
### 3.4 Shop (店铺)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---------------------- | -------- | -------------------------------- |
|
||||
| id | string | 店铺 ID |
|
||||
| owner | User | 店长信息 |
|
||||
| name | string | 店铺名称 |
|
||||
| banner | string | 店铺招牌图 (可选) |
|
||||
| description | string | 店铺简介 |
|
||||
| rating | number | 综合评分 |
|
||||
| totalOrders | number | 总单量 |
|
||||
| playerCount | number | 打手数量 |
|
||||
| commissionType | string | 抽成方式 (`fixed`, `percentage`) |
|
||||
| commissionValue | number | 抽成数值 |
|
||||
| allowMultiShop | boolean | 是否允许打手兼职 |
|
||||
| allowIndependentOrders | boolean | 是否允许打手私下接单 |
|
||||
| dispatchMode | string | 派单模式 (`manual`, `auto`) |
|
||||
| announcements | string[] | 店铺公告列表 |
|
||||
| templateConfig | object | 店铺主页模板配置 |
|
||||
|
||||
### 3.5 Order (订单)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------ | ------------- | ------------------- |
|
||||
| id | string | 订单 ID |
|
||||
| consumerId | string | 消费者 ID |
|
||||
| consumerName | string | 消费者昵称 |
|
||||
| playerId | string | 打手 ID |
|
||||
| playerName | string | 打手昵称 |
|
||||
| shopId | string | 店铺 ID (可选) |
|
||||
| shopName | string | 店铺名称 (可选) |
|
||||
| service | PlayerService | 购买的服务快照 |
|
||||
| status | OrderStatus | 订单状态 |
|
||||
| totalPrice | number | 订单总价 |
|
||||
| note | string | 备注 (可选) |
|
||||
| createdAt | string | 创建时间 |
|
||||
| acceptedAt | string | 接单时间 (可选) |
|
||||
| closedAt | string | 申请结算时间 (可选) |
|
||||
| completedAt | string | 完成时间 (可选) |
|
||||
|
||||
**OrderStatus 枚举**: `pending_payment`, `pending_accept`, `in_progress`, `pending_close`, `pending_review`, `disputed`, `completed`, `cancelled`
|
||||
|
||||
### 3.6 Review (评价)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| -------------- | ------- | -------------------------------------- |
|
||||
| id | string | 评价 ID |
|
||||
| orderId | string | 关联订单 ID |
|
||||
| fromUserId | string | 评价人 ID |
|
||||
| fromUserName | string | 评价人昵称 |
|
||||
| fromUserAvatar | string | 评价人头像 |
|
||||
| toUserId | string | 被评价人 ID |
|
||||
| rating | number | 评分 (1-5) |
|
||||
| content | string | 评价内容 (可选) |
|
||||
| sealed | boolean | 是否处于密封状态(双方未互评前不可见) |
|
||||
| createdAt | string | 评价时间 |
|
||||
|
||||
### 3.7 Dispute & DisputeRecord (争议)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------------ | -------- | ---------------------------------------------------------- |
|
||||
| id | string | 争议 ID |
|
||||
| orderId | string | 关联订单 ID |
|
||||
| initiatorId | string | 发起人 ID |
|
||||
| initiatorName | string | 发起人昵称 |
|
||||
| reason | string | 争议原因 |
|
||||
| evidence | string[] | 证据图片 URL 列表 |
|
||||
| status | string | 状态 (`open`, `reviewing`, `resolved`, `appealed`) |
|
||||
| result | string | 仲裁结果 (`full_refund`, `full_payment`, `partial_refund`) |
|
||||
| respondentReason | string | 被诉方回应理由 (可选) |
|
||||
| respondentEvidence | string[] | 被诉方证据 (可选) |
|
||||
| appealReason | string | 申诉理由 (可选) |
|
||||
| appealedAt | string | 申诉时间 (可选) |
|
||||
| timeline | object[] | 争议处理时间线 |
|
||||
| createdAt | string | 创建时间 |
|
||||
|
||||
### 3.8 ChatSession & ChatMessage (聊天)
|
||||
**ChatSession**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------- | -------- | ---------------------------------- |
|
||||
| id | string | 会话 ID |
|
||||
| type | string | 会话类型 (`order`, `consultation`) |
|
||||
| orderId | string | 关联订单 ID (仅 order 类型) |
|
||||
| participants | object[] | 参与者列表 `{id, name, avatar}` |
|
||||
| lastMessage | string | 最后一条消息内容 |
|
||||
| lastMessageAt | string | 最后一条消息时间 |
|
||||
| unreadCount | number | 未读消息数 |
|
||||
| readonly | boolean | 是否只读(如订单完成/咨询超时后) |
|
||||
|
||||
**ChatMessage**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------ | ------ | ------------------------------------ |
|
||||
| id | string | 消息 ID |
|
||||
| sessionId | string | 会话 ID |
|
||||
| senderId | string | 发送者 ID |
|
||||
| senderName | string | 发送者昵称 |
|
||||
| senderAvatar | string | 发送者头像 |
|
||||
| type | string | 消息类型 (`text`, `image`, `system`) |
|
||||
| content | string | 消息内容 |
|
||||
| createdAt | string | 发送时间 |
|
||||
|
||||
### 3.9 Post & Comment (社区)
|
||||
**Post**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ------------- | -------- | ---------------------- |
|
||||
| id | string | 帖子 ID |
|
||||
| author | User | 作者信息 |
|
||||
| authorRole | UserRole | 作者发帖时的角色 |
|
||||
| title | string | 标题 |
|
||||
| content | string | 内容 |
|
||||
| images | string[] | 图片列表 |
|
||||
| tags | string[] | 标签列表 |
|
||||
| linkedOrderId | string | 关联的订单 ID (秀单帖) |
|
||||
| quotedPostId | string | 引用的帖子 ID (引用帖) |
|
||||
| likeCount | number | 点赞数 |
|
||||
| commentCount | number | 评论数 |
|
||||
| liked | boolean | 当前用户是否已赞 |
|
||||
| pinned | boolean | 是否被作者置顶 |
|
||||
| createdAt | string | 发布时间 |
|
||||
|
||||
**Comment**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------- | ---------------- |
|
||||
| id | string | 评论 ID |
|
||||
| postId | string | 关联帖子 ID |
|
||||
| author | User | 评论者信息 |
|
||||
| content | string | 评论内容 |
|
||||
| likeCount | number | 点赞数 |
|
||||
| liked | boolean | 当前用户是否已赞 |
|
||||
| createdAt | string | 评论时间 |
|
||||
|
||||
### 3.10 WalletTransaction (钱包流水)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ----------- | ------ | ----------------------------------------------------------- |
|
||||
| id | string | 流水 ID |
|
||||
| type | string | 类型 (`topup`, `payment`, `income`, `withdrawal`, `refund`) |
|
||||
| amount | number | 金额 (正数为收入/充值/退款,负数为支出/提现) |
|
||||
| description | string | 描述 |
|
||||
| orderId | string | 关联订单 ID (可选,用于结构化统计,替代前端正则匹配) |
|
||||
| createdAt | string | 发生时间 |
|
||||
|
||||
### 3.11 Favorite (收藏)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| ---------- | ------ | ------------------------------- |
|
||||
| id | string | 收藏 ID |
|
||||
| userId | string | 用户 ID |
|
||||
| targetType | string | 收藏目标类型 (`player`, `shop`) |
|
||||
| targetId | string | 收藏目标 ID |
|
||||
| createdAt | string | 收藏时间 |
|
||||
|
||||
### 3.12 Notification (通知)
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --------- | ------- | ----------------------------------------- |
|
||||
| id | string | 通知 ID |
|
||||
| type | string | 通知类型 (`order`, `community`, `system`) |
|
||||
| title | string | 通知标题 |
|
||||
| content | string | 通知内容 |
|
||||
| read | boolean | 是否已读 |
|
||||
| link | string | 关联跳转链接 (可选) |
|
||||
| createdAt | string | 通知时间 |
|
||||
---
|
||||
|
||||
## 4. 认证与用户
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ------ | ------------------------------------- | ---- | --------------------------------- |
|
||||
| POST | `/auth/register` | | 用户注册 (username + email + password + vcode) |
|
||||
| POST | `/auth/login` | | 用户登录 (username + password) |
|
||||
| POST | `/auth/logout` | 🔒 | 退出登录,清除 Cookie |
|
||||
| POST | `/auth/forgot-password/send` | | 忘记密码(发送验证码到邮箱) |
|
||||
| POST | `/auth/reset-password` | | 重置密码 (email + vcode + newPassword) |
|
||||
| POST | `/email/verification-code/send` | | 发送邮箱验证码 |
|
||||
| GET | `/users/me` | 🔒 | 获取当前登录用户信息 |
|
||||
| PUT | `/users/me` | 🔒 | 更新个人资料 (昵称, 头像, 简介等) |
|
||||
| POST | `/users/me/switch-role` | 🔒 | 切换当前激活角色 |
|
||||
| POST | `/users/me/verification` | 🔒 | 提交角色认证材料 |
|
||||
| GET | `/users/me/verification` | 🔒 | 获取认证状态 |
|
||||
| PUT | `/users/me/preferences/notifications` | 🔒 | 更新通知偏好设置 |
|
||||
| PUT | `/users/me/preferences/theme` | 🔒 | 更新主题偏好设置 |
|
||||
| GET | `/users/:id` | | 获取指定用户信息 |
|
||||
| POST | `/users/:id/follow` | 🔒 | 关注用户 |
|
||||
| DELETE | `/users/:id/follow` | 🔒 | 取消关注用户 |
|
||||
|
||||
**请求示例:登录**
|
||||
|
||||
```json
|
||||
// POST /api/v1/auth/login
|
||||
{
|
||||
"username": "zhangsan",
|
||||
"password": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例:登录成功**
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"id": "u1",
|
||||
"username": "zhangsan",
|
||||
"email": "zhangsan@example.com",
|
||||
"nickname": "张三",
|
||||
"avatar": "https://cdn.juwan.com/avatars/u1.jpg",
|
||||
"role": "consumer",
|
||||
"verifiedRoles": ["consumer", "player"],
|
||||
"verificationStatus": { "consumer": "approved", "player": "approved" },
|
||||
"createdAt": "2024-01-15T08:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例:提交认证材料**
|
||||
```json
|
||||
// POST /api/v1/users/me/verification
|
||||
{
|
||||
"role": "player",
|
||||
"materials": {
|
||||
"idCardFront": "https://cdn.juwan.com/uploads/xxx.jpg",
|
||||
"idCardBack": "https://cdn.juwan.com/uploads/yyy.jpg",
|
||||
"gameScreenshot": "https://cdn.juwan.com/uploads/zzz.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例:切换角色**
|
||||
```json
|
||||
// POST /api/v1/users/me/switch-role
|
||||
{ "role": "player" }
|
||||
```
|
||||
|
||||
**请求示例:更新通知偏好**
|
||||
```json
|
||||
// PUT /api/v1/users/me/preferences/notifications
|
||||
{ "order": true, "community": true, "system": false }
|
||||
```
|
||||
---
|
||||
|
||||
## 5. 游戏数据
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | ------------ | ---- | ----------------------- |
|
||||
| GET | `/games` | | 获取游戏列表 (支持分页) |
|
||||
| GET | `/games/:id` | | 获取指定游戏详情 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 打手与服务
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ------ | ----------------------- | ---- | ------------------------------------------------- |
|
||||
| GET | `/players` | | 获取打手列表 (支持分页、筛选) |
|
||||
| GET | `/players/:id` | | 获取打手公开主页详情 |
|
||||
| PUT | `/players/me/status` | 🔒 | 更新打手接单状态 (`available`, `busy`, `offline`) |
|
||||
| GET | `/services` | | 获取所有服务列表 |
|
||||
| GET | `/services/:id` | | 获取服务详情 |
|
||||
| GET | `/players/:id/services` | | 获取指定打手的服务列表 |
|
||||
| POST | `/services` | 🔒 | 创建服务 (仅 player) |
|
||||
| PUT | `/services/:id` | 🔒 | 更新服务 (仅 player) |
|
||||
| DELETE | `/services/:id` | 🔒 | 删除服务 (仅 player) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 店铺系统
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ------ | ------------------------------- | ---- | --------------------------- |
|
||||
| GET | `/shops` | | 获取店铺列表 |
|
||||
| GET | `/shops/:id` | | 获取店铺公开主页详情 |
|
||||
| GET | `/users/:id/shop` | | 获取指定店长的店铺 |
|
||||
| POST | `/shops` | 🔒 | 创建店铺 (仅 owner) |
|
||||
| PUT | `/shops/:id` | 🔒 | 更新店铺基础信息及规则 |
|
||||
| PUT | `/shops/:id/template` | 🔒 | 更新店铺主页模板配置 |
|
||||
| PUT | `/shops/:id/announcements` | 🔒 | 更新店铺公告 |
|
||||
| POST | `/shops/:id/invitations` | 🔒 | 邀请打手加入店铺 |
|
||||
| POST | `/shops/invitations/:id/accept` | 🔒 | 打手接受店铺邀请 |
|
||||
| DELETE | `/shops/:id/players/:playerId` | 🔒 | 将打手移出店铺 |
|
||||
| GET | `/shops/:id/income-stats` | 🔒 | 获取店铺收入统计 (仅 owner) |
|
||||
|
||||
| GET | `/shops/mine` | 🔒 | 获取当前登录店主的店铺详情 |
|
||||
| GET | `/shops/:id/players` | | 获取店铺下的打手列表 |
|
||||
| POST | `/shops/:id/announcements` | 🔒 | 新增店铺公告 |
|
||||
| DELETE | `/shops/:id/announcements/:index` | 🔒 | 删除店铺公告 |
|
||||
| DELETE | `/shops/invitations/:id` | 🔒 | 拒绝店铺邀请 (打手调用) |
|
||||
|
||||
**请求示例:更新店铺规则**
|
||||
```json
|
||||
// PUT /api/v1/shops/:id
|
||||
{
|
||||
"name": "星耀电竞工作室",
|
||||
"description": "专业英雄联盟代练工作室",
|
||||
"commissionType": "percentage",
|
||||
"commissionValue": 15,
|
||||
"allowMultiShop": false,
|
||||
"allowIndependentOrders": true,
|
||||
"dispatchMode": "auto"
|
||||
}
|
||||
```
|
||||
**请求示例:更新店铺模板**
|
||||
```json
|
||||
// PUT /api/v1/shops/:id/template
|
||||
{
|
||||
"sections": [
|
||||
{ "type": "banner", "enabled": true, "order": 0 },
|
||||
{ "type": "intro", "enabled": true, "order": 1 },
|
||||
{ "type": "services", "enabled": true, "order": 2 },
|
||||
{ "type": "players", "enabled": true, "order": 3 },
|
||||
{ "type": "announcements", "enabled": false, "order": 4 },
|
||||
{ "type": "reviews", "enabled": true, "order": 5 }
|
||||
]
|
||||
}
|
||||
```
|
||||
**响应示例:店铺收入统计**
|
||||
```json
|
||||
// GET /api/v1/shops/:id/income-stats
|
||||
{
|
||||
"monthlyIncome": 12800.00,
|
||||
"pendingSettlement": 3200.00,
|
||||
"totalWithdrawn": 54000.00,
|
||||
"totalOrders": 156,
|
||||
"completedOrders": 142
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
## 8. 订单系统
|
||||
|
||||
### 8.1 订单状态机 (Order State Machine)
|
||||
|
||||
| 当前状态 | 触发动作 | 下一状态 | 允许的角色 | 副作用 |
|
||||
| ----------------- | ----------------------------- | ---------------- | ---------------- | -------------------------------------------------- |
|
||||
| `pending_payment` | `PAY` | `pending_accept` | consumer | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT |
|
||||
| `pending_accept` | `ACCEPT` | `in_progress` | player, owner | CLEAR_TIMEOUT, SYNC_CHAT_SESSION |
|
||||
| `pending_accept` | `CANCEL_PRE_ACCEPT` | `cancelled` | consumer | CLEAR_TIMEOUT |
|
||||
| `pending_accept` | `AUTO_TIMEOUT_PENDING_ACCEPT` | `cancelled` | system | CLEAR_TIMEOUT |
|
||||
| `in_progress` | `REQUEST_CLOSE` | `pending_close` | consumer, player | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT, SYNC_CHAT_SESSION |
|
||||
| `in_progress` | `OPEN_DISPUTE` | `disputed` | consumer, player | CLEAR_TIMEOUT, SYNC_CHAT_SESSION |
|
||||
| `pending_close` | `CONFIRM_CLOSE` | `pending_review` | consumer, player | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT, SYNC_CHAT_SESSION |
|
||||
| `pending_close` | `OPEN_DISPUTE` | `disputed` | consumer, player | CLEAR_TIMEOUT, SYNC_CHAT_SESSION |
|
||||
| `pending_close` | `AUTO_TIMEOUT_PENDING_CLOSE` | `pending_review` | system | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT, SYNC_CHAT_SESSION |
|
||||
| `pending_review` | `SUBMIT_REVIEW` | `completed` | consumer, player | CLEAR_TIMEOUT, PAYOUT_INCOME, SYNC_CHAT_SESSION |
|
||||
| `pending_review` | `AUTO_TIMEOUT_PENDING_REVIEW` | `completed` | system | CLEAR_TIMEOUT, PAYOUT_INCOME, SYNC_CHAT_SESSION |
|
||||
| `disputed` | `RESOLVE_DISPUTE` | `pending_review` | owner, admin | CLEAR_TIMEOUT, SCHEDULE_TIMEOUT, SYNC_CHAT_SESSION |
|
||||
|
||||
### 8.2 超时配置 (Timeout Configs)
|
||||
后端需实现定时任务(如 Redis 延迟队列)来处理以下超时逻辑:
|
||||
- `ORDER_ACCEPT_TIMEOUT_MS`: 待接单超时自动取消(默认 5 分钟)。
|
||||
- `ORDER_CLOSE_TIMEOUT_MS`: 申请结算后对方未确认,超时自动进入待评价(默认 24 小时)。
|
||||
- `ORDER_REVIEW_TIMEOUT_MS`: 待评价超时自动好评并完成订单(默认 72 小时)。
|
||||
|
||||
### 8.3 订单接口
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | --------------------------- | ---- | ----------------------------------- |
|
||||
| GET | `/orders` | 🔒 | 获取当前用户的订单列表 |
|
||||
| GET | `/orders/:id` | 🔒 | 获取订单详情 |
|
||||
| POST | `/orders` | 🔒 | 创建订单 (状态: `pending_payment`) |
|
||||
| POST | `/orders/paid` | 🔒 | 创建并直接支付订单 (快捷下单) |
|
||||
| POST | `/orders/:id/pay` | 🔒 | 支付订单 (`PAY`) |
|
||||
| POST | `/orders/:id/accept` | 🔒 | 接单 (`ACCEPT`) |
|
||||
| POST | `/orders/:id/request-close` | 🔒 | 申请结算 (`REQUEST_CLOSE`) |
|
||||
| POST | `/orders/:id/confirm-close` | 🔒 | 确认结算 (`CONFIRM_CLOSE`) |
|
||||
| POST | `/orders/:id/cancel` | 🔒 | 接单前取消 (`CANCEL_PRE_ACCEPT`) |
|
||||
| POST | `/orders/:id/reorder` | 🔒 | 再来一单 (基于原订单快速创建新订单) |
|
||||
`GET /orders` 支持查询参数:`role` (consumer/player/owner)、`status` (状态过滤)、`offset`、`limit`。后端根据 Token 中的 userId 和 role 参数自动过滤,不允许查看他人订单。
|
||||
|
||||
**请求示例:创建并支付订单 (快捷下单)**
|
||||
```json
|
||||
// POST /api/v1/orders/paid
|
||||
{
|
||||
"playerId": "p1",
|
||||
"shopId": "shop1",
|
||||
"serviceId": "svc1",
|
||||
"quantity": 3,
|
||||
"note": "希望晚上8点后开始"
|
||||
}
|
||||
```
|
||||
**响应示例:订单创建成功**
|
||||
```json
|
||||
{
|
||||
"order": {
|
||||
"id": "ord-20240320-001",
|
||||
"consumerId": "u1",
|
||||
"consumerName": "张三",
|
||||
"playerId": "p1",
|
||||
"playerName": "小明",
|
||||
"shopId": "shop1",
|
||||
"shopName": "星耀电竞",
|
||||
"service": { "id": "svc1", "title": "英雄联盟代练", "price": 50, "unit": "局" },
|
||||
"status": "pending_accept",
|
||||
"totalPrice": 150,
|
||||
"createdAt": "2024-03-20T12:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
**请求示例:再来一单**
|
||||
```json
|
||||
// POST /api/v1/orders/:id/reorder
|
||||
// 无请求体,后端基于原订单的 playerId、serviceId、shopId 创建新订单
|
||||
```
|
||||
---
|
||||
|
||||
## 9. 争议仲裁
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | ------------------------ | ---- | ------------------------- |
|
||||
| GET | `/disputes` | 🔒 | 获取当前用户的争议列表 |
|
||||
| GET | `/orders/:id/dispute` | 🔒 | 获取指定订单的争议详情 |
|
||||
| POST | `/orders/:id/dispute` | 🔒 | 发起争议 (`OPEN_DISPUTE`) |
|
||||
| POST | `/disputes/:id/response` | 🔒 | 被诉方提交回应及证据 |
|
||||
| POST | `/disputes/:id/appeal` | 🔒 | 对仲裁结果发起申诉 |
|
||||
|
||||
**争议处理时间线 (Timeline)**:
|
||||
- `created`: 争议发起
|
||||
- `response`: 被诉方回应
|
||||
- `reviewing`: 店长/平台介入审查
|
||||
- `resolved`: 给出仲裁结果
|
||||
- `appealed`: 发起申诉,转交平台管理员
|
||||
**请求示例:发起争议**
|
||||
```json
|
||||
// POST /api/v1/orders/:id/dispute
|
||||
{
|
||||
"reason": "打手未按约定时间上线",
|
||||
"evidence": [
|
||||
"https://cdn.juwan.com/uploads/evidence1.jpg",
|
||||
"https://cdn.juwan.com/uploads/evidence2.jpg"
|
||||
]
|
||||
}
|
||||
```
|
||||
**请求示例:被诉方回应**
|
||||
```json
|
||||
// POST /api/v1/disputes/:id/response
|
||||
{
|
||||
"reason": "当时网络故障,已与客户沟通并补时",
|
||||
"evidence": ["https://cdn.juwan.com/uploads/response1.jpg"]
|
||||
}
|
||||
```
|
||||
**请求示例:申诉**
|
||||
```json
|
||||
// POST /api/v1/disputes/:id/appeal
|
||||
{
|
||||
"reason": "仲裁结果不合理,请求平台复核"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 评价系统
|
||||
|
||||
**密封机制 (Sealed Mechanics)**:
|
||||
评价提交后默认为 `sealed: true`。只有当双方都完成评价,或者评价超时(`ORDER_REVIEW_TIMEOUT_MS`)后,评价才会解封并公开显示。
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | --------------------- | ---- | ------------------------------------ |
|
||||
| POST | `/orders/:id/review` | 🔒 | 提交评价 (`SUBMIT_REVIEW`) |
|
||||
| GET | `/reviews` | | 获取公开评价列表 |
|
||||
| GET | `/orders/:id/reviews` | 🔒 | 获取指定订单的评价(受密封机制限制) |
|
||||
| GET | `/users/:id/reviews` | | 获取指定用户收到的评价 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 聊天系统
|
||||
|
||||
### 11.1 会话类型
|
||||
- **`order`**: 订单会话。随订单创建而建立,订单完成后变为只读。
|
||||
- **`consultation`**: 咨询会话。用户在下单前与打手沟通。24小时无回复自动关闭。可升级为订单会话。
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | ----------------------------- | ---- | ----------------------------------- |
|
||||
| GET | `/chat/sessions` | 🔒 | 获取当前用户的会话列表 |
|
||||
| GET | `/chat/sessions/:id` | 🔒 | 获取会话详情 |
|
||||
| GET | `/chat/sessions/:id/messages` | 🔒 | 获取会话历史消息 (分页) |
|
||||
| POST | `/chat/sessions/order` | 🔒 | 确保订单会话存在 (不存在则创建) |
|
||||
| POST | `/chat/sessions/consultation` | 🔒 | 创建咨询会话 |
|
||||
| POST | `/chat/sessions/:id/upgrade` | 🔒 | 将咨询会话升级为订单会话 |
|
||||
| POST | `/chat/sessions/:id/messages` | 🔒 | 发送消息 (文本/图片) |
|
||||
| GET | `/shops/:id/chat-sessions` | 🔒 | 店长查看员工的业务会话 (需员工同意) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 社区系统
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ------ | --------------------- | ---- | ------------------------------------- |
|
||||
| GET | `/posts` | | 获取帖子列表 (支持分页、标签筛选) |
|
||||
| GET | `/posts/:id` | | 获取帖子详情 |
|
||||
| POST | `/posts` | 🔒 | 发布帖子 (支持普通帖、秀单帖、引用帖) |
|
||||
| POST | `/posts/:id/like` | 🔒 | 点赞帖子 |
|
||||
| DELETE | `/posts/:id/like` | 🔒 | 取消点赞帖子 |
|
||||
| POST | `/posts/:id/pin` | 🔒 | 作者置顶帖子 (最多 N 条) |
|
||||
| DELETE | `/posts/:id/pin` | 🔒 | 取消置顶 |
|
||||
| GET | `/posts/:id/comments` | | 获取帖子评论列表 |
|
||||
| POST | `/posts/:id/comments` | 🔒 | 发表评论 |
|
||||
| POST | `/comments/:id/like` | 🔒 | 点赞评论 |
|
||||
| DELETE | `/comments/:id/like` | 🔒 | 取消点赞评论 |
|
||||
| GET | `/users/:id/posts` | | 获取指定用户的帖子列表 |
|
||||
`GET /posts` 支持查询参数:`tags` (标签过滤)、`gameId` (游戏过滤)、`sortBy` (new/hot)、`offset`、`limit`。
|
||||
**请求示例:发布秀单帖**
|
||||
```json
|
||||
// POST /api/v1/posts
|
||||
{
|
||||
"title": "超棒的代练体验",
|
||||
"content": "从银到铂金,只用了三天!",
|
||||
"images": ["https://cdn.juwan.com/uploads/post1.jpg"],
|
||||
"tags": ["英雄联盟", "代练"],
|
||||
"linkedOrderId": "ord-20240320-001"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. 收藏与关注
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ------ | ---------------------------- | ---- | ---------------------------------- |
|
||||
| GET | `/favorites` | 🔒 | 获取当前用户的收藏列表 (打手/店铺) |
|
||||
| POST | `/favorites` | 🔒 | 添加收藏 |
|
||||
| DELETE | `/favorites/:id` | 🔒 | 取消收藏 |
|
||||
| GET | `/users/:id/favorites/check` | 🔒 | 检查是否已收藏指定目标 |
|
||||
**请求示例:添加收藏**
|
||||
```json
|
||||
// POST /api/v1/favorites
|
||||
{ "targetType": "player", "targetId": "p1" }
|
||||
```
|
||||
**检查收藏状态**
|
||||
```
|
||||
GET /api/v1/users/u1/favorites/check?targetType=player&targetId=p1
|
||||
→ { "favorited": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 搜索与发现
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | --------- | ---- | ------------ |
|
||||
| GET | `/search` | | 统一搜索接口 |
|
||||
|
||||
**请求参数**:
|
||||
- `q`: 搜索关键词
|
||||
- `selectedGames`: 游戏 ID 数组
|
||||
- `min`, `max`: 价格区间
|
||||
- `onlyOnline`: 是否仅看在线 (`true`/`false`)
|
||||
- `minRating`: 最低评分
|
||||
- `sort`: 排序方式 (`composite`, `rating`, `orders`, `price_asc`, `price_desc`)
|
||||
- `offset`, `limit`: 分页参数
|
||||
| GET | `/recommendations/home` | | 首页推荐信息流 (混合打手与店铺卡片) |
|
||||
**响应示例:搜索结果**
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"type": "player",
|
||||
"player": { "id": "p1", "user": {...}, "rating": 4.8, "status": "available", ... },
|
||||
"minPrice": 30,
|
||||
"unit": "局",
|
||||
"rating": 4.8,
|
||||
"orders": 256
|
||||
},
|
||||
{
|
||||
"type": "shop",
|
||||
"shop": { "id": "shop1", "name": "星耀电竞", ... },
|
||||
"minPrice": 25,
|
||||
"unit": "局",
|
||||
"rating": 4.6,
|
||||
"orders": 1024,
|
||||
"games": ["英雄联盟", "CS2"],
|
||||
"hasAvailable": true
|
||||
}
|
||||
],
|
||||
"meta": { "total": 42, "offset": 0, "limit": 12 }
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
## 15. 钱包与资金
|
||||
|
||||
### 15.1 收入计算公式 (Income Calculation)
|
||||
订单完成后,系统根据店铺规则计算打手实际收入:
|
||||
- **无店铺 (独立接单)**: `income = totalPrice`
|
||||
- **比例抽成 (percentage)**: `income = totalPrice * (1 - commissionValue / 100)`
|
||||
- **固定抽成 (fixed)**: `income = Math.max(0, totalPrice - commissionValue)`
|
||||
剩余部分作为店铺收入(若有店铺)。
|
||||
|
||||
### 15.2 钱包接口
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | ---------------------- | ---- | ------------------------------------- |
|
||||
| GET | `/wallet/balance` | 🔒 | 获取当前余额 |
|
||||
| GET | `/wallet/transactions` | 🔒 | 获取资金流水 (包含结构化的 `orderId`) |
|
||||
| POST | `/wallet/topup` | 🔒 | 充值 |
|
||||
| POST | `/wallet/withdraw` | 🔒 | 提现 |
|
||||
|
||||
---
|
||||
|
||||
## 16. 通知系统
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | ---------------------------------- | ---- | ------------------ |
|
||||
| GET | `/notifications` | 🔒 | 获取通知列表 |
|
||||
| PUT | `/notifications/:id/read` | 🔒 | 标记单条通知为已读 |
|
||||
| PUT | `/notifications/read-all` | 🔒 | 标记所有通知为已读 |
|
||||
| POST | `/notifications/push-subscription` | 🔒 | 订阅 Web Push 推送 |
|
||||
|
||||
---
|
||||
|
||||
## 17. 管理后台 (Admin)
|
||||
|
||||
| 方法 | 路径 | 权限 | 说明 |
|
||||
| ---- | ---------------------------------- | ---- | -------------------------- |
|
||||
| GET | `/admin/verifications` | 🛡️ | 获取待审核的认证申请 |
|
||||
| POST | `/admin/verifications/:id/approve` | 🛡️ | 批准认证申请 |
|
||||
| POST | `/admin/verifications/:id/reject` | 🛡️ | 拒绝认证申请 |
|
||||
| GET | `/admin/disputes` | 🛡️ | 获取需要平台介入的争议列表 |
|
||||
| POST | `/admin/disputes/:id/resolve` | 🛡️ | 平台管理员给出最终仲裁结果 |
|
||||
|
||||
---
|
||||
|
||||
## 18. WebSocket 事件
|
||||
|
||||
客户端连接到 `wss://api.juwan.com/api/v1/ws`,通过 JWT 鉴权。
|
||||
|
||||
**下发事件类型**:
|
||||
- `chat:message`: 收到新聊天消息。
|
||||
- `order:status_changed`: 订单状态变更(触发前端重新拉取订单详情)。
|
||||
- `notification:new`: 收到新系统通知。
|
||||
- `dispute:updated`: 争议状态或时间线更新。
|
||||
- `wallet:balance_changed`: 余额变动通知。
|
||||
|
||||
---
|
||||
|
||||
## 19. 安全与校验清单
|
||||
|
||||
1. **数据隔离 (Data Isolation)**:
|
||||
- 用户只能查询自己的订单、钱包流水、私聊会话。
|
||||
- 店长只能查询本店铺的订单和员工数据。
|
||||
2. **并发控制 (Concurrency Control)**:
|
||||
- 订单状态流转必须使用乐观锁或数据库事务,防止并发导致状态机错乱。
|
||||
- 钱包扣款必须保证原子性,防止超扣。
|
||||
3. **权限校验 (Authorization)**:
|
||||
- 严格校验 `Actor` 的 `role`。例如,只有 `player` 才能创建服务,只有 `owner` 才能修改店铺规则。
|
||||
- 订单操作必须校验操作者是否为该订单的 `consumerId`、`playerId` 或关联的 `shopId` 的店长。
|
||||
4. **幂等性 (Idempotency)**:
|
||||
- 支付、接单、结算等核心操作必须保证幂等性,重复请求返回 `IDEMPOTENT_NOOP`。
|
||||
-344
@@ -1,344 +0,0 @@
|
||||
# 隐蔽未实现接口与逻辑清单
|
||||
|
||||
本报告聚焦于"看起来已实现、实际未接通或存在断层"的功能点。这些问题在 mock 演示阶段不易察觉,切换真实后端后会表现为数据丢失、页面空态、交互无效等事故。
|
||||
|
||||
与第一份报告(《静态模拟数据残留审计报告》)互补,本报告不再重复 mock 数据源、store 初始化、伪 API 层等已知问题。
|
||||
|
||||
---
|
||||
|
||||
## 一、登录态与持久化缺失
|
||||
|
||||
### 1.1 记住登录状态 — 纯展示控件
|
||||
|
||||
登录页渲染了"记住登录状态"复选框,但该控件未被表单注册,无任何读取或写入逻辑。
|
||||
|
||||
| 位置 | 内容 |
|
||||
| ------------------------------ | -------------------------------------------------------------------------- |
|
||||
| `app/(auth)/login/page.tsx:86` | `<Checkbox id="remember" />` — 未绑定 `register("remember")` 或 `useState` |
|
||||
|
||||
### 1.2 登录态无持久化 — 刷新即丢
|
||||
|
||||
全仓库未发现 `localStorage`、`sessionStorage`、`cookie` 写入,也未使用 `zustand/middleware` 的 `persist`。`store/auth.ts` 的 `login()` 仅写内存态,页面刷新后回到未登录状态。
|
||||
|
||||
**影响**:切真实后端后,如果前端仍依赖 Zustand 内存态判断登录,刷新会导致用户被踢出。
|
||||
|
||||
---
|
||||
|
||||
## 二、身份与实体模型错位
|
||||
|
||||
### 2.1 当前用户与店铺/打手 ID 不匹配 — 后台大面积空态
|
||||
|
||||
登录固定为 `mockUsers[0]`(id=`u1`),但 mock 数据中的店铺 owner 是 `u10`/`u11`/`u12`,打手是 `u5`/`u6` 起。
|
||||
|
||||
| 位置 | 内容 |
|
||||
| -------------------------------------- | ---------------------------------------------- |
|
||||
| `lib/mock/users.ts:119` | `currentUser = mockUsers[0]` — 固定 `u1` |
|
||||
| `lib/mock/shops.ts:7` 起 | 店铺 owner 为 `u10`、`u11`、`u12` |
|
||||
| `lib/mock/players.ts:7` 起 | 打手 user 从 `u5` 开始 |
|
||||
| `lib/domain/resolve-current-shop.ts:5` | `shops.find(shop => shop.owner.id === userId)` |
|
||||
|
||||
**结果**:用户切换到店主身份后,`resolveOwnerShop` 始终返回 `null`,以下页面全部显示"当前账号没有可管理的店铺":
|
||||
|
||||
- `app/(dashboard)/dashboard/shop/employees/page.tsx:67`
|
||||
- `app/(dashboard)/dashboard/shop/rules/page.tsx:29`
|
||||
- `app/(dashboard)/dashboard/shop/income/page.tsx:28`
|
||||
- `app/(dashboard)/dashboard/shop/page.tsx` 同理
|
||||
- `app/(dashboard)/dashboard/shop/templates/page.tsx` 同理
|
||||
- `app/(dashboard)/dashboard/shop/orders/page.tsx` 同理
|
||||
|
||||
### 2.2 打手主页链接指向不存在的打手
|
||||
|
||||
导航栏在 player 身份下跳转 `/player/${user.id}`(`components/header.tsx:88`),但 `user.id` 是 `u1`,`mockPlayers` 中没有 id 为 `u1` 的打手,会触发 `notFound()`(`app/(main)/player/[id]/page.tsx:25`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、用户动作无持久化 — 刷新即回退
|
||||
|
||||
以下交互在前端有即时反馈,但数据仅存在于 Zustand 内存态,刷新页面或换设备后全部丢失。
|
||||
|
||||
### 3.1 点赞
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `components/post-like-button.tsx:21-32` | 调用 `togglePostLike` → `store/posts.ts:50-61` 本地翻转 `liked` 并 ±1 |
|
||||
|
||||
### 3.2 评论
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ------------------------------------- | ------------------------------------------------ |
|
||||
| `components/post-comments.tsx:29-52` | 调用 `addComment` → `store/comments.ts` 本地追加 |
|
||||
| `components/post-comment-count.tsx:3` | 读 `useCommentStore` 本地计数 |
|
||||
|
||||
### 3.3 收藏
|
||||
|
||||
| 位置 | 行为 |
|
||||
| -------------------------------------- | ----------------------------------------------------- |
|
||||
| `components/favorite-button.tsx:29-33` | 调用 `toggleFavorite` → `store/favorites.ts` 本地增删 |
|
||||
|
||||
### 3.4 通知已读
|
||||
|
||||
| 位置 | 行为 |
|
||||
| -------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `app/(account)/notifications/page.tsx` | 调用 `markAsRead` / `markAllAsRead` → `store/notifications.ts` 本地标记 |
|
||||
| `components/header.tsx:81-83` | 未读数通过 `.filter(n => !n.read).length` 本地计算 |
|
||||
|
||||
### 3.5 设置保存
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------- | ---------------------------------------------------- |
|
||||
| `app/(account)/settings/page.tsx` | 昵称、简介、通知偏好等修改仅写 `useAuthStore` 内存态 |
|
||||
|
||||
---
|
||||
|
||||
## 四、上传功能 — 占位或 Object URL
|
||||
|
||||
所有"上传"操作要么是纯占位 UI,要么使用 `URL.createObjectURL` 生成本地临时链接,刷新后失效。
|
||||
|
||||
### 4.1 头像上传
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ------------------------------------ | ------------------------------------------------------------- |
|
||||
| `app/(account)/settings/page.tsx:75` | `setAvatar(URL.createObjectURL(file))` — 本地预览,无上传请求 |
|
||||
|
||||
### 4.2 聊天图片发送
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `app/(order)/chat/[id]/page.tsx:152` | `sendImageMessage(session.id, URL.createObjectURL(file))` — blob URL 作为消息内容 |
|
||||
|
||||
### 4.3 争议证据上传
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `app/(order)/dispute/[id]/page.tsx:88` | `URL.createObjectURL(file)` 生成预览,提交时传入 store |
|
||||
| `app/(order)/dispute/[id]/page.tsx:102` | `URL.revokeObjectURL` 移除时释放 — 说明开发者意识到了临时性,但未替换为真实上传 |
|
||||
|
||||
### 4.4 身份认证证明材料 — 纯占位
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `app/(account)/verify/page.tsx:170-181` | 三个 `<div>` 占位块(身份证正面/反面/游戏截图),有 `cursor-pointer` 样式但无 `<input type="file">`、无 `onClick`、无状态绑定 |
|
||||
|
||||
### 4.5 发帖图片 — 假计数 + 固定路径
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `app/(main)/post/new/page.tsx:46` | `imageCount` 状态仅做数字加减 |
|
||||
| `app/(main)/post/new/page.tsx:84` | 提交时 `images: Array.from({ length: imageCount }).map(() => "/posts/p1-1.jpg")` — 无论"上传"几张,全部指向同一张固定图片 |
|
||||
|
||||
---
|
||||
|
||||
## 五、客户端与服务端数据隔离
|
||||
|
||||
部分页面是 Server Component(或在服务端执行的函数),通过 `lib/api/*` 读取数据;而写入操作发生在客户端 Zustand store。在 Next.js 的 SSR/RSC 模式下,服务端无法读取客户端 store 的最新状态。
|
||||
|
||||
### 5.1 发帖 → 帖子详情
|
||||
|
||||
| 写入 | 读取 |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `app/(main)/post/new/page.tsx:79` → `store/posts.ts:44` (client) | `app/(main)/post/[id]/page.tsx:17` → `lib/api/posts.ts:10` → `usePostStore.getState()` (server?) |
|
||||
|
||||
### 5.2 店铺模板保存 → 店铺主页
|
||||
|
||||
| 写入 | 读取 |
|
||||
| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `app/(dashboard)/dashboard/shop/templates/page.tsx:120` → `store/shops.ts` (client) | `app/(main)/shop/[id]/page.tsx:29` → `shop.templateConfig.sections` (server) |
|
||||
|
||||
### 5.3 服务发布 → 打手详情页
|
||||
|
||||
| 写入 | 读取 |
|
||||
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `app/(dashboard)/dashboard/services/new/page.tsx:132` → `store/services.ts` (client) | `app/(main)/player/[id]/page.tsx:29-32` — 优先读 `player.services`(mock 内嵌数据),仅当为空时才 fallback 到 `listServicesByPlayer` |
|
||||
|
||||
**特别注意**:打手详情页的 `player.services && player.services.length > 0` 判断(`app/(main)/player/[id]/page.tsx:30`)意味着只要 mock 数据中打手自带了 services,新发布的服务就永远不会显示。这是一个数据遮蔽问题,不仅仅是隔离问题。
|
||||
|
||||
---
|
||||
|
||||
## 六、纯前端筛选/排序/统计
|
||||
|
||||
以下逻辑在前端内存中对全量数据做 filter/sort/slice,mock 阶段数据量小时体验正常,接后端引入分页、权限、跨端一致性后会出现偏差。
|
||||
|
||||
### 6.1 社区列表 — 内存排序与筛选
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ------------------------------------- | ---------------------------------------------------------- |
|
||||
| `app/(main)/community/page.tsx:22-34` | 全量 `posts.filter().sort()` 实现"最新/最热"和游戏标签筛选 |
|
||||
|
||||
### 6.2 订单列表 — 内存角色过滤 + Tab 过滤
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `app/(order)/orders/page.tsx:91-103` | 先按 `consumerId/playerId/shopId` 过滤角色视角,再按 tab 过滤状态 |
|
||||
|
||||
### 6.3 管理后台概览 — 硬取首项 + 截断
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------------- | --------------------------------------------------------- |
|
||||
| `app/(dashboard)/dashboard/page.tsx:17` | `listPlayers()[0]` — 始终取第一个打手的数据作为"我的"数据 |
|
||||
| `app/(dashboard)/dashboard/page.tsx:18` | `listShops()[0]` — 始终取第一个店铺 |
|
||||
| `app/(dashboard)/dashboard/page.tsx:19` | `listOrders().slice(0, 3)` — 最近订单截前 3 条 |
|
||||
|
||||
### 6.4 首页推荐 — 全量渲染
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------- | ---------------------------------------------------------------------- |
|
||||
| `app/(main)/page.tsx:12-14` | `listPlayers()` / `listShops()` 全量获取后直接渲染,无推荐算法、无分页 |
|
||||
|
||||
### 6.5 收入统计 — 正则匹配交易描述关联订单
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ------------------------------------------------------ | -------------------------------------------------------------------- |
|
||||
| `app/(dashboard)/dashboard/shop/income/page.tsx:51-53` | `transaction.description.match(/ord[-\d]+/)` 从描述文本中提取订单 ID |
|
||||
|
||||
**风险**:后端描述格式变化时,统计数据会静默丢失,不报错。应由后端提供结构化的 `orderId` 字段。
|
||||
|
||||
---
|
||||
|
||||
## 七、消息列表未按用户过滤
|
||||
|
||||
| 位置 | 行为 |
|
||||
| -------------------------------------- | ----------------------------------------------------------- |
|
||||
| `app/(order)/chat/page.tsx:12` | `sessions` 直接全量渲染,未过滤当前用户是否为参与者 |
|
||||
| `app/(order)/chat/page.tsx:21-23` | 仅用 `participant.id !== userId` 找"对方",但不排除无关会话 |
|
||||
| `app/(order)/chat/[id]/page.tsx:52-59` | 会话详情页才做参与者校验 |
|
||||
|
||||
**结果**:列表页会展示当前用户不参与的会话,点进去才提示无权查看。
|
||||
|
||||
---
|
||||
|
||||
## 八、店铺规则 — 可保存但不执行
|
||||
|
||||
### 8.1 规则字段仅做展示
|
||||
|
||||
| 字段 | 保存位置 | 执行位置 |
|
||||
| ------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `allowMultiShop` | `app/(dashboard)/dashboard/shop/rules/page.tsx:50` → `updateShop` | 无 — 未发现任何校验逻辑 |
|
||||
| `allowIndependentOrders` | 同上 | 无 — 未发现任何校验逻辑 |
|
||||
| `dispatchMode` | 同上 | `store/orders.ts:407` — 仅影响前端自动派单模拟 |
|
||||
| `commissionType` / `commissionValue` | 同上 | `lib/domain/income.ts:22-31` — 仅影响前端收入计算展示 |
|
||||
|
||||
### 8.2 员工邀请 — 无校验直接重绑定
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `app/(dashboard)/dashboard/shop/employees/page.tsx:74-79` | 点击"邀请打手"直接调用 `assignToShop` + `playerCount + 1` |
|
||||
| `store/players.ts:13-17` | `assignToShop` 仅修改 `shopId`/`shopName` 字段,无校验 |
|
||||
|
||||
**缺失**:未检查打手是否已属于其他店铺、是否符合 `allowMultiShop` 规则、是否需要打手同意。
|
||||
|
||||
### 8.3 公告编辑 — `window.prompt` 无审计
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------------------- | ------------------------------------------ |
|
||||
| `app/(dashboard)/dashboard/shop/page.tsx:157` | `window.prompt("", announcement)` 编辑公告 |
|
||||
| `app/(dashboard)/dashboard/shop/page.tsx:175` | `window.prompt("", "")` 新增公告 |
|
||||
|
||||
---
|
||||
|
||||
## 九、社区列表的点赞/评论入口缺失
|
||||
|
||||
| 位置 | 行为 |
|
||||
| --------------------------------------- | -------------------------------------------------------------- |
|
||||
| `app/(main)/community/page.tsx:145-154` | 点赞和评论图标是 `<span>` 内的纯展示元素,无 `onClick`,无链接 |
|
||||
|
||||
帖子详情页(`components/post-like-button.tsx`、`components/post-comments.tsx`)有完整的点赞和评论交互,但社区列表页的卡片上这些图标仅做数字展示,用户无法在列表页直接操作。
|
||||
|
||||
---
|
||||
|
||||
## 十、置顶/精选 — 有字段和展示,无操作入口
|
||||
|
||||
| 位置 | 内容 |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `lib/types.ts:177` | `Post` 类型定义 `pinned: boolean` |
|
||||
| `lib/mock/posts.ts:17` 等 | mock 帖子中有 `pinned: true` |
|
||||
| `app/(main)/community/page.tsx:104` | 展示 `<Pin>` 图标 |
|
||||
| `store/posts.ts:40` | `createPost` 强制 `pinned: false` |
|
||||
| `store/posts.ts:17-22` | store 接口仅有 `createPost` / `togglePostLike` / `incrementCommentCount`,无 pin/unpin 方法 |
|
||||
|
||||
`PLAN.md:141` 规划了"用户自己置顶,最多 N 条",但目前无任何操作路径可以改变 `pinned` 状态。
|
||||
|
||||
---
|
||||
|
||||
## 十一、关注与推送 — 文案存在,实现缺席
|
||||
|
||||
### 11.1 关注
|
||||
|
||||
| 位置 | 内容 |
|
||||
| ------------------------------------- | ------------------------------- |
|
||||
| `PLAN.md:9` | "消费者可以收藏/关注打手或店铺" |
|
||||
| `app/(account)/settings/page.tsx:234` | 通知偏好文案"点赞、评论、关注" |
|
||||
|
||||
全仓库未发现 `follow`/`关注` 相关的 store、api、页面动作。
|
||||
|
||||
### 11.2 浏览器推送
|
||||
|
||||
| 位置 | 内容 |
|
||||
| ------------- | ------------------------------- |
|
||||
| `PLAN.md:216` | "站内通知 + 用户可选浏览器推送" |
|
||||
|
||||
全仓库未发现 `Notification.requestPermission`、`serviceWorker`、`PushSubscription`、`pushManager` 等 Web Push 相关代码。现有通知体系仅为本地生成 + 本地已读。
|
||||
|
||||
---
|
||||
|
||||
## 十二、硬编码展示值
|
||||
|
||||
这些数值直接写在 JSX 中,不来自任何 store 或 API 计算。
|
||||
|
||||
| 位置 | 内容 |
|
||||
| --------------------------------------- | -------------------------------------------------------- |
|
||||
| `app/(account)/wallet/page.tsx:154` | `¥1,280.00`(本月收入) |
|
||||
| `app/(account)/wallet/page.tsx:158` | `¥320.00`(待结算) |
|
||||
| `app/(account)/wallet/page.tsx:162` | `¥5,400.00`(已提现) |
|
||||
| `app/(dashboard)/dashboard/page.tsx:79` | `¥12,800`(店主本月收入) |
|
||||
| `app/(auth)/layout.tsx:4-8` | `12,000+` 认证打手 / `98.6%` 好评率 / `50,000+` 完成订单 |
|
||||
| `app/(order)/dispute/[id]/page.tsx:377` | UI 文案含"模拟处理结果"字样 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、未使用的基础设施
|
||||
|
||||
### 13.1 `requestWithAuth` — 定义未调用
|
||||
|
||||
| 位置 | 内容 |
|
||||
| --------------------- | ----------------------------------------------- |
|
||||
| `lib/api/client.ts:9` | 定义了 `requestWithAuth<T>(executor, options?)` |
|
||||
| 全仓库 | `rg "requestWithAuth("` 命中 0 处调用 |
|
||||
|
||||
### 13.2 `usePlayerStatusStore` — 定义未调用
|
||||
|
||||
| 位置 | 内容 |
|
||||
| --------------------------- | ----------------------------------------------------------- |
|
||||
| `store/player-status.ts:11` | 定义了 `usePlayerStatusStore`,含 `statuses` 和 `setStatus` |
|
||||
| 全仓库 | `rg "usePlayerStatusStore"` 仅命中定义处 |
|
||||
|
||||
`PLAN.md:107` 规划了"打手有并发接单上限,搜索结果和打手详情页展示'可接单/忙碌'状态",该 store 疑似为此功能的未完成基础设施。
|
||||
|
||||
---
|
||||
|
||||
## 迁移优先级建议
|
||||
|
||||
### P0 — 上线前必须解决(数据安全/业务正确性)
|
||||
|
||||
1. 登录态持久化(刷新丢失 = 用户无法正常使用)
|
||||
2. 身份与实体 ID 对齐(否则后台全部空态)
|
||||
3. 所有用户写入动作接入后端持久化(点赞/评论/收藏/设置/通知已读)
|
||||
4. 上传功能替换为真实文件上传(头像/聊天图片/争议证据/认证材料/发帖图片)
|
||||
5. 移除 UI 中的"模拟"字样(`dispute/[id]/page.tsx:377`)
|
||||
|
||||
### P1 — 切后端时必须改造(数据一致性)
|
||||
|
||||
6. 消息列表按当前用户过滤会话
|
||||
7. 筛选/排序/统计改为后端分页查询(社区/订单/首页推荐/收入统计)
|
||||
8. 打手详情页移除 `player.services` 优先读取逻辑,统一从服务列表查询
|
||||
9. 店铺规则执行逻辑落地(`allowMultiShop`/`allowIndependentOrders` 校验)
|
||||
10. 员工邀请增加校验流程
|
||||
11. 收入统计改用结构化字段关联订单
|
||||
|
||||
### P2 — 功能补齐或明确下线
|
||||
|
||||
12. 记住登录状态功能实现或移除控件
|
||||
13. 置顶/精选操作入口
|
||||
14. 关注功能
|
||||
15. 浏览器推送
|
||||
16. 打手在线状态(`usePlayerStatusStore` 接入或移除)
|
||||
17. 公告编辑替换 `window.prompt` 为正式表单
|
||||
-260
@@ -1,260 +0,0 @@
|
||||
# 静态模拟数据残留审计报告
|
||||
|
||||
## 总体结论
|
||||
|
||||
项目当前没有任何真实后端 HTTP 请求。全仓库仅 1 处 `fetch(`(`lib/api/search.ts:34`),请求的是本地 Next.js Route `/api/search`,而该 Route 本身也以 mock 数据为源。`@tanstack/react-query` 的 `QueryClientProvider` 已挂载(`app/providers.tsx`),但全仓库 0 处 `useQuery`/`useMutation` 调用。无 `.env` 文件,无 mock/real 环境切换开关。
|
||||
|
||||
---
|
||||
|
||||
## 一、静态实体数据源 `lib/mock/*.ts`
|
||||
|
||||
15 个文件,定义了全部业务实体的硬编码数组,是整个模拟体系的根:
|
||||
|
||||
| 文件 | 导出 | 实体类型 |
|
||||
| ------------------------------ | --------------------------------------- | ------------ |
|
||||
| `lib/mock/users.ts:3` | `mockUsers: User[]` | 用户 |
|
||||
| `lib/mock/users.ts:119` | `currentUser = mockUsers[0]` | 当前登录用户 |
|
||||
| `lib/mock/games.ts:3` | `mockGames: Game[]` | 游戏 |
|
||||
| `lib/mock/services.ts:3` | `mockServices: PlayerService[]` | 陪玩服务 |
|
||||
| `lib/mock/players.ts:5` | `mockPlayers: Player[]` | 打手 |
|
||||
| `lib/mock/shops.ts:4` | `mockShops: Shop[]` | 店铺 |
|
||||
| `lib/mock/orders.ts:4` | `mockOrders: Order[]` | 订单 |
|
||||
| `lib/mock/disputes.ts:3` | `mockDisputes: Dispute[]` | 争议 |
|
||||
| `lib/mock/reviews.ts:3` | `mockReviews: Review[]` | 评价 |
|
||||
| `lib/mock/posts.ts:4` | `mockPosts: Post[]` | 帖子 |
|
||||
| `lib/mock/comments.ts:4` | `mockComments: Comment[]` | 评论 |
|
||||
| `lib/mock/chat.ts:3` | `mockChatSessions: ChatSession[]` | 聊天会话 |
|
||||
| `lib/mock/chat.ts:95` | `mockChatMessages: ChatMessage[]` | 聊天消息 |
|
||||
| `lib/mock/favorites.ts:3` | `mockFavorites: Favorite[]` | 收藏 |
|
||||
| `lib/mock/notifications.ts:3` | `mockNotifications: Notification[]` | 通知 |
|
||||
| `lib/mock/transactions.ts:3` | `mockTransactions: WalletTransaction[]` | 交易流水 |
|
||||
| `lib/mock/transactions.ts:139` | `walletBalance = 275` | 钱包余额 |
|
||||
|
||||
聚合出口:`lib/mock/index.ts`
|
||||
|
||||
---
|
||||
|
||||
## 二、Store 层 — mock 初始化 + 前端本地状态机
|
||||
|
||||
12 个 Zustand store 全部以 mock 数据初始化,运行时在前端内存中增删改查并生成 ID/时间戳:
|
||||
|
||||
| Store 文件 | 初始化行 | mock 来源 |
|
||||
| --------------------------- | -------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `store/shops.ts:14` | `shops: mockShops` | `mockShops` |
|
||||
| `store/players.ts:12` | `players: mockPlayers` | `mockPlayers` |
|
||||
| `store/services.ts:14` | `services: mockServices` | `mockServices` |
|
||||
| `store/orders.ts:315` | `orders: mockOrders` | `mockOrders` |
|
||||
| `store/disputes.ts:203` | `disputes: mockDisputes.map(asRecord)` | `mockDisputes` |
|
||||
| `store/reviews.ts:51` | `reviews: mockReviews` | `mockReviews` + `mockUsers`(:24) |
|
||||
| `store/posts.ts:25` | `posts: mockPosts` | `mockPosts` |
|
||||
| `store/comments.ts:13` | `comments: mockComments` | `mockComments` |
|
||||
| `store/chat.ts:23-24` | `sessions: mockChatSessions, messages: mockChatMessages` | `mockChatSessions` + `mockChatMessages` + `mockUsers`(:15) |
|
||||
| `store/favorites.ts:14` | `favorites: mockFavorites` | `mockFavorites` |
|
||||
| `store/notifications.ts:21` | `notifications: mockNotifications` | `mockNotifications` |
|
||||
| `store/wallet.ts:20-21` | `balance: walletBalance, transactions: mockTransactions` | `walletBalance` + `mockTransactions` |
|
||||
|
||||
所有 store 还通过 `generateId()` 在前端本地生成实体 ID(`store/orders.ts:318,350`、`store/services.ts:21`、`store/wallet.ts:29,45,71,102,132`、`store/disputes.ts:231` 等),切后端后 ID 应由服务端分配。
|
||||
|
||||
---
|
||||
|
||||
## 三、伪 API 层 `lib/api/*.ts`
|
||||
|
||||
15 个模块名义上是 API 层,实际全部是同步读写本地 store 或直接返回 mock:
|
||||
|
||||
| 文件 | 实际行为 | 风险 |
|
||||
| ---------------------------- | -------------------------------------------------- | --------------------------- |
|
||||
| `lib/api/users.ts:1,4,8,12` | 直接 `import { currentUser, mockUsers }` 并 return | 高 — 直接返回 mock |
|
||||
| `lib/api/games.ts:1,4,8` | 直接 `import { mockGames }` 并 return | 高 — 直接返回 mock |
|
||||
| `lib/api/orders.ts:11` | `useOrderStore.getState().orders` | 中 — 读本地 store |
|
||||
| `lib/api/services.ts:4` | `useServiceStore.getState().services` | 中 — 读本地 store |
|
||||
| `lib/api/players.ts:4` | `usePlayerStore.getState().players` | 中 — 读本地 store |
|
||||
| `lib/api/shops.ts:4` | `useShopStore.getState().shops` | 中 — 读本地 store |
|
||||
| `lib/api/posts.ts:7` | `usePostStore.getState().posts` | 中 — 读写本地 store |
|
||||
| `lib/api/comments.ts:8` | `useCommentStore.getState().comments` | 中 — 读写本地 store |
|
||||
| `lib/api/chat.ts:6` | `useChatStore.getState().sessions` | 中 — 读写本地 store |
|
||||
| `lib/api/favorites.ts:4` | `useFavoriteStore.getState().favorites` | 中 — 读本地 store |
|
||||
| `lib/api/notifications.ts:7` | `useNotificationStore.getState().notifications` | 中 — 读写本地 store |
|
||||
| `lib/api/transactions.ts:4` | `useWalletStore.getState().transactions` | 中 — 读本地 store |
|
||||
| `lib/api/reviews.ts:6` | `useReviewStore.getState().reviews` | 中 — 读写本地 store |
|
||||
| `lib/api/disputes.ts:6` | `useDisputeStore.getState().disputes` | 中 — 读写本地 store |
|
||||
| `lib/api/search.ts:34` | `fetch('/api/search?...')` | 唯一 fetch,但后端仍是 mock |
|
||||
| `lib/api/client.ts:9` | `requestWithAuth` 仅包装执行器做未登录拦截 | 无网络请求 |
|
||||
|
||||
---
|
||||
|
||||
## 四、唯一 HTTP 链路 — 搜索
|
||||
|
||||
```
|
||||
lib/api/search.ts:34 → fetch(`/api/search?${params}`)
|
||||
app/api/search/route.ts:1 → import { mockPlayers, mockServices, mockShops } from "@/lib/mock"
|
||||
app/api/search/route.ts:51 → players: mockPlayers, shops: mockShops, services: mockServices
|
||||
```
|
||||
|
||||
搜索是项目里唯一走了 HTTP 请求的链路,但 Next.js Route Handler 的数据源仍然是 mock。
|
||||
|
||||
---
|
||||
|
||||
## 五、前端状态自动推进(Demo 定时器)
|
||||
|
||||
这是切后端时事故概率最高的部分 — 前端 `setTimeout` 自动推进业务状态,真实后端应由服务端事件驱动。
|
||||
|
||||
**定时常量:**
|
||||
```
|
||||
lib/config/demo-timers.ts:1 ORDER_ACCEPT_TIMEOUT_MS = 30_000
|
||||
lib/config/demo-timers.ts:2 ORDER_CLOSE_TIMEOUT_MS = 30_000
|
||||
lib/config/demo-timers.ts:3 ORDER_REVIEW_TIMEOUT_MS = 30_000
|
||||
lib/config/demo-timers.ts:5 DISPUTE_TO_REVIEWING_MS = 5_000
|
||||
lib/config/demo-timers.ts:6 DISPUTE_TO_RESOLVED_MS = 10_000
|
||||
```
|
||||
|
||||
**订单自动流转:**
|
||||
| 位置 | 行为 |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `store/orders.ts:172-206` | `scheduleOrderTimeout` — 订单进入 pending_accept/pending_close/pending_review 后启动 setTimeout 自动超时流转 |
|
||||
| `store/orders.ts:405-413` | 自动派单模拟 — 当店铺 `dispatchMode === "auto"` 时,`setTimeout(3000)` 自动调用 `acceptOrder` |
|
||||
|
||||
**争议自动推进:**
|
||||
| 位置 | 行为 |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `store/disputes.ts:142-163` | `setTimeout(DISPUTE_TO_REVIEWING_MS)` — 自动将争议从 open → reviewing |
|
||||
| `store/disputes.ts:165-197` | `setTimeout(DISPUTE_TO_RESOLVED_MS)` — 自动将争议从 reviewing → resolved 并回写订单 |
|
||||
|
||||
---
|
||||
|
||||
## 六、认证/表单流程模拟
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ---------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `app/(auth)/login/page.tsx:35-36` | `await new Promise(r => setTimeout(r, 500))` + `login(getCurrentUserForLogin(), ...)` |
|
||||
| `app/(auth)/register/page.tsx:50-51` | 同上 |
|
||||
| `components/login-dialog.tsx:44-45` | 同上 |
|
||||
| `app/(auth)/forgot-password/page.tsx:27` | `await new Promise(r => setTimeout(r, 500))` + toast |
|
||||
| `app/(account)/verify/page.tsx:41-52` | `submitWithMockApproval` — 提交认证后 `setTimeout(3000)` 自动调用 `approveVerification` |
|
||||
|
||||
`getCurrentUserForLogin()` 最终来自 `lib/api/users.ts:12` → `lib/mock/users.ts:119` 的 `currentUser = mockUsers[0]`,即无论输入什么账号密码,登录的永远是同一个硬编码用户。
|
||||
|
||||
---
|
||||
|
||||
## 七、硬编码展示值
|
||||
|
||||
| 位置 | 内容 |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `app/(account)/wallet/page.tsx:154` | `¥1,280.00`(本月收入,写死) |
|
||||
| `app/(account)/wallet/page.tsx:158` | `¥320.00`(待结算,写死) |
|
||||
| `app/(account)/wallet/page.tsx:162` | `¥5,400.00`(已提现,写死) |
|
||||
| `app/(auth)/layout.tsx:4-8` | `12,000+` 认证打手 / `98.6%` 好评率 / `50,000+` 完成订单(营销数字,写死) |
|
||||
| `app/(order)/dispute/[id]/page.tsx:377` | UI 文案含"模拟处理结果"字样 |
|
||||
|
||||
---
|
||||
|
||||
## 八、页面数据源分类(34 个页面)
|
||||
|
||||
**Store Only(14 个)— 完全不经过 lib/api,直接读写 store:**
|
||||
```
|
||||
app/(account)/notifications/page.tsx
|
||||
app/(account)/settings/page.tsx
|
||||
app/(account)/verify/page.tsx [含 setTimeout 模拟]
|
||||
app/(account)/wallet/page.tsx
|
||||
app/(dashboard)/dashboard/services/page.tsx
|
||||
app/(dashboard)/dashboard/shop/employees/page.tsx
|
||||
app/(dashboard)/dashboard/shop/orders/page.tsx
|
||||
app/(dashboard)/dashboard/shop/page.tsx
|
||||
app/(dashboard)/dashboard/shop/rules/page.tsx
|
||||
app/(dashboard)/dashboard/shop/templates/page.tsx [含 setTimeout]
|
||||
app/(main)/post/new/page.tsx
|
||||
app/(order)/chat/page.tsx
|
||||
app/(order)/order/[id]/page.tsx
|
||||
app/(order)/orders/page.tsx
|
||||
```
|
||||
|
||||
**API + Store 混合(9 个)— 经过 lib/api 但 lib/api 本身也是读 store:**
|
||||
```
|
||||
app/(auth)/login/page.tsx [含 setTimeout 模拟登录]
|
||||
app/(auth)/register/page.tsx [含 setTimeout 模拟注册]
|
||||
app/(dashboard)/dashboard/page.tsx
|
||||
app/(dashboard)/dashboard/services/new/page.tsx
|
||||
app/(dashboard)/dashboard/shop/income/page.tsx
|
||||
app/(order)/chat/[id]/page.tsx
|
||||
app/(order)/dispute/[id]/page.tsx
|
||||
app/(order)/order/new/page.tsx [含 setTimeout]
|
||||
app/(order)/review/[id]/page.tsx
|
||||
```
|
||||
|
||||
**API Only(7 个)— 仅经过 lib/api,但 lib/api 底层仍是本地数据:**
|
||||
```
|
||||
app/(main)/page.tsx
|
||||
app/(main)/community/page.tsx
|
||||
app/(main)/player/[id]/page.tsx
|
||||
app/(main)/post/[id]/page.tsx
|
||||
app/(main)/search/page.tsx [唯一真正 fetch 的页面]
|
||||
app/(main)/shop/[id]/page.tsx
|
||||
app/(main)/user/[id]/page.tsx
|
||||
```
|
||||
|
||||
**无 API/Store(4 个)— 纯静态或纯前端模拟:**
|
||||
```
|
||||
app/(auth)/forgot-password/page.tsx [setTimeout 模拟]
|
||||
app/(main)/help/page.tsx [纯静态内容]
|
||||
app/(main)/privacy/page.tsx [纯静态内容]
|
||||
app/(main)/terms/page.tsx [纯静态内容]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、组件层直接读写业务 Store(绕过 lib/api)
|
||||
|
||||
除 `useAuthStore`/`useLoginDialogStore` 外,以下共享组件直接操作业务 store:
|
||||
|
||||
| 组件 | 直接引用的 store |
|
||||
| ------------------------------------- | ----------------------------------------------- |
|
||||
| `components/order-actions.tsx:14-16` | `useChatStore`, `useOrderStore`, `useShopStore` |
|
||||
| `components/favorite-button.tsx:6` | `useFavoriteStore` |
|
||||
| `components/post-like-button.tsx:5` | `usePostStore` |
|
||||
| `components/post-comments.tsx:5` | `useCommentStore` |
|
||||
| `components/post-comment-count.tsx:3` | `useCommentStore` |
|
||||
| `components/header.tsx:19-20` | `useNotificationStore`, `useShopStore` |
|
||||
|
||||
---
|
||||
|
||||
## 十、已排除项(确认不存在)
|
||||
|
||||
- 无 `.env` / `.env.local` / `.env.development` 等环境配置文件
|
||||
- 无 mock/real 环境切换开关(`NEXT_PUBLIC_*MOCK*`、`USE_MOCK` 等)
|
||||
- 无运行时 MSW 拦截代码(`msw` 仅作为 `@vitest/mocker` 的 peer dep 出现在 lockfile)
|
||||
- 无 `axios`、`axios-mock-adapter`、`json-server`、`miragejs` 使用
|
||||
- 无运行时 `.json` 文件作为数据源导入
|
||||
- 无 `Promise.resolve` 模拟异步返回
|
||||
- 无 Next.js `rewrites`/`redirects`/proxy 配置
|
||||
- `useQuery`/`useMutation`/`useSWR` 全仓库 0 处调用
|
||||
|
||||
---
|
||||
|
||||
## 十一、隐蔽未实现接口(更像真功能,实际仍在前端自转)
|
||||
|
||||
这些条目即使去掉了明显的 mock 字样,也会在切真实后端后造成严重偏差:UI 提示可用或可保存,实际没有任何可被后端接入的请求/数据契约/权限边界。
|
||||
|
||||
| 类型 | 现象 | 关键证据(单行) | 切后端后常见失败模式 |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| Client Store 与 Server Component 断层 | 管理页或发布页写入 Zustand 后,跳转到详情/公开页看不到改动 | `app/(main)/post/new/page.tsx:79`(createPost 写前端 store) / `app/(main)/post/[id]/page.tsx:17`(详情页从 server 侧读 getPostById) | 新创建内容在列表可见,详情页 404 或展示旧数据;店铺模板预览不反映保存结果 |
|
||||
| 图片/文件上传未落到接口 | 多处上传只生成本地 blob URL 或占位图,刷新或换设备即失效 | `app/(account)/settings/page.tsx:75`、`app/(order)/chat/[id]/page.tsx:152`、`app/(order)/dispute/[id]/page.tsx:88`、`app/(main)/post/new/page.tsx:84`、`app/(account)/verify/page.tsx:170` | 后端接入后出现上传缺字段、图片无法复现、消息图片仅本机可见 |
|
||||
| 会话列表未按参与者过滤 | 消息列表直接渲染全部会话,只在详情页才做参与者校验 | `app/(order)/chat/page.tsx:12` | 列表出现无关会话,后端接入时需要补齐按用户查询与分页 |
|
||||
| 店铺规则字段缺少业务约束 | 规则页可保存 allowMultiShop 等字段,员工邀请直接改归属,不存在申请/同意流 | `app/(dashboard)/dashboard/shop/rules/page.tsx:48` / `app/(dashboard)/dashboard/shop/employees/page.tsx:75` / `store/players.ts:13` | 规则与权限不生效,导致运营规则与实际行为脱节 |
|
||||
| 智能派单与流程推进为固定定时器 | 自动派单固定 3 秒自动接单;待接单/结单/评价自动超时推进;争议自动进入 reviewing/resolved | `store/orders.ts:186`、`store/orders.ts:408`、`store/disputes.ts:142` | 与真实状态机/消息队列/人工审核不一致,产生错误状态与资金流偏差 |
|
||||
| 交易明细与订单关联靠字符串解析 | 收入统计用正则从 transaction.description 里提取 ord id | `app/(dashboard)/dashboard/shop/income/page.tsx:51` | 后端字段结构化后该逻辑失效,统计漏算或误算 |
|
||||
| 身份切换与实体模型不对齐 | 登录永远是固定用户 u1(consumer),UI 允许切换到 player/owner;导航用 user.id 直接拼 player/shop 路由 | `app/(auth)/login/page.tsx:36` / `lib/mock/users.ts:119` / `components/header.tsx:85` / `lib/mock/players.ts:7` / `lib/mock/shops.ts:7` | 打手/店主页面长期空态或 404;店铺后台永远提示无可管理店铺 |
|
||||
| 看似走 API,实际仍为 mock | `/search` 会发起 fetch,但 `/api/search` 路由以 mockPlayers/mockShops/mockServices 作为数据源 | `lib/api/search.ts:34` / `app/api/search/route.ts:1` | 接真实后端时容易遗漏替换点,导致线上仍走假数据 |
|
||||
|
||||
---
|
||||
|
||||
## 复扫命令
|
||||
|
||||
后续持续监控可用以下 6 条命令覆盖主要信号:
|
||||
|
||||
```bash
|
||||
rg -n '@/lib/mock' app lib store components
|
||||
rg -n '\bfetch\(' app lib store components
|
||||
rg -n 'setTimeout\(' app lib store components
|
||||
rg -n 'from "@/store/' app components
|
||||
rg -n 'URL\.createObjectURL' app components
|
||||
rg -n 'window\.prompt' app
|
||||
```
|
||||
Reference in New Issue
Block a user