haha
This commit is contained in:
@@ -432,7 +432,9 @@ def get_product_with_prices(request, product_id: int):
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||
"""Search approved products by name or description."""
|
||||
"""Search approved products by name, description, or by user_id (submitter)."""
|
||||
from apps.users.models import User
|
||||
|
||||
prices_prefetch = Prefetch(
|
||||
"prices",
|
||||
queryset=ProductPrice.objects.select_related("website"),
|
||||
@@ -444,6 +446,12 @@ def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||
)
|
||||
if filters.category_id:
|
||||
products = products.filter(category_id=filters.category_id)
|
||||
if filters.user_id:
|
||||
try:
|
||||
submitter = User.objects.get(user_id=filters.user_id)
|
||||
products = products.filter(submitted_by=submitter)
|
||||
except User.DoesNotExist:
|
||||
products = products.none()
|
||||
|
||||
needs_price_stats = (
|
||||
filters.min_price is not None
|
||||
|
||||
14
backend/apps/products/migrations/0005_merge_20260130_1626.py
Normal file
14
backend/apps/products/migrations/0005_merge_20260130_1626.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-30 08:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0002_product_images'),
|
||||
('products', '0004_product_reject_reason_product_reviewed_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
]
|
||||
@@ -157,6 +157,7 @@ class ProductFilter(FilterSchema):
|
||||
class ProductSearchFilter(FilterSchema):
|
||||
"""Product search filter schema."""
|
||||
category_id: Optional[int] = None
|
||||
user_id: Optional[str] = None # 按用户ID筛选该用户提交的商品
|
||||
min_price: Optional[Decimal] = None
|
||||
max_price: Optional[Decimal] = None
|
||||
sort_by: Optional[str] = None
|
||||
|
||||
@@ -5,9 +5,9 @@ from .models import User
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
list_display = ['id', 'open_id', 'name', 'email', 'role', 'is_active', 'created_at']
|
||||
list_display = ['id', 'user_id', 'open_id', 'name', 'email', 'role', 'is_active', 'created_at']
|
||||
list_filter = ['role', 'is_active', 'is_staff']
|
||||
search_fields = ['open_id', 'name', 'email']
|
||||
search_fields = ['user_id', 'open_id', 'name', 'email']
|
||||
ordering = ['-created_at']
|
||||
|
||||
fieldsets = (
|
||||
|
||||
@@ -20,6 +20,7 @@ def serialize_user_brief(user: User) -> UserBrief:
|
||||
return UserBrief(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
user_id=user.user_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
@@ -58,6 +59,15 @@ def list_friends(request):
|
||||
return friends
|
||||
|
||||
|
||||
@router.get("/requests/incoming/count", auth=JWTAuth())
|
||||
def incoming_count(request):
|
||||
"""Lightweight: only return count of pending incoming requests (for badge)."""
|
||||
count = FriendRequest.objects.filter(
|
||||
receiver=request.auth, status=FriendRequest.Status.PENDING
|
||||
).count()
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.get("/requests/incoming", response=List[FriendRequestOut], auth=JWTAuth())
|
||||
def list_incoming_requests(request):
|
||||
"""List incoming friend requests."""
|
||||
@@ -171,12 +181,18 @@ def cancel_request(request, request_id: int):
|
||||
|
||||
@router.get("/search", response=List[UserBrief], auth=JWTAuth())
|
||||
def search_users(request, q: Optional[str] = None, limit: int = 10):
|
||||
"""Search users by open_id or name."""
|
||||
"""Search users by user_id (exact), open_id or name."""
|
||||
if not q:
|
||||
return []
|
||||
|
||||
q_clean = (q or "").strip()
|
||||
queryset = (
|
||||
User.objects.filter(Q(open_id__icontains=q) | Q(name__icontains=q))
|
||||
User.objects.filter(
|
||||
Q(user_id__iexact=q_clean)
|
||||
| Q(user_id__icontains=q_clean)
|
||||
| Q(open_id__icontains=q_clean)
|
||||
| Q(name__icontains=q_clean)
|
||||
)
|
||||
.exclude(id=request.auth.id)
|
||||
.order_by("id")[: max(1, min(limit, 50))]
|
||||
)
|
||||
|
||||
48
backend/apps/users/migrations/0005_add_user_id.py
Normal file
48
backend/apps/users/migrations/0005_add_user_id.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated manually for user_id (shareable ID for friend/product search)
|
||||
|
||||
import secrets
|
||||
import string
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def generate_user_id_for_user(User):
|
||||
alphabet = string.ascii_lowercase + string.digits
|
||||
for _ in range(10):
|
||||
candidate = "U" + "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
if not User.objects.filter(user_id=candidate).exists():
|
||||
return candidate
|
||||
return "U" + secrets.token_hex(4)
|
||||
|
||||
|
||||
def populate_user_id(apps, schema_editor):
|
||||
User = apps.get_model("users", "User")
|
||||
for user in User.objects.filter(user_id__isnull=True).iterator():
|
||||
user.user_id = generate_user_id_for_user(User)
|
||||
user.save(update_fields=["user_id"])
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0004_rename_friendreq_receiver_status_idx_friend_requ_receive_383c2c_idx_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="user_id",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="可分享的用户ID,用于好友搜索、商品搜索等",
|
||||
max_length=16,
|
||||
null=True,
|
||||
unique=True,
|
||||
verbose_name="用户ID",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(populate_user_id, noop),
|
||||
]
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
User models for authentication and profile management.
|
||||
"""
|
||||
import secrets
|
||||
import string
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
||||
|
||||
@@ -37,6 +39,14 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
open_id = models.CharField('OpenID', max_length=64, unique=True)
|
||||
user_id = models.CharField(
|
||||
'用户ID',
|
||||
max_length=16,
|
||||
unique=True,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text='可分享的用户ID,用于好友搜索、商品搜索等',
|
||||
)
|
||||
name = models.CharField('用户名', max_length=255, blank=True, null=True)
|
||||
email = models.EmailField('邮箱', max_length=320, blank=True, null=True)
|
||||
avatar = models.TextField('头像', blank=True, null=True)
|
||||
@@ -70,11 +80,26 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
indexes = [
|
||||
models.Index(fields=["role", "is_active"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
models.Index(fields=["user_id"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name or self.open_id
|
||||
|
||||
def _generate_user_id(self):
|
||||
"""Generate a unique shareable user_id (e.g. U8x3k2m1)."""
|
||||
alphabet = string.ascii_lowercase + string.digits
|
||||
for _ in range(10):
|
||||
candidate = "U" + "".join(secrets.choice(alphabet) for _ in range(8))
|
||||
if not User.objects.filter(user_id=candidate).exists():
|
||||
return candidate
|
||||
return "U" + secrets.token_hex(4)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = self._generate_user_id()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
return self.role == self.Role.ADMIN
|
||||
|
||||
@@ -10,6 +10,7 @@ class UserOut(Schema):
|
||||
"""Public user output schema."""
|
||||
id: int
|
||||
open_id: str
|
||||
user_id: Optional[str] = None # 可分享的用户ID,用于好友/商品搜索
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
@@ -28,6 +29,7 @@ class UserBrief(Schema):
|
||||
"""Minimal user info for social features."""
|
||||
id: int
|
||||
open_id: str
|
||||
user_id: Optional[str] = None # 可分享的用户ID
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
5594
frontend/package-lock.json
generated
Normal file
5594
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.453.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pnpm": "^10.28.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
|
||||
@@ -1,41 +1,54 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NotFound from "@/features/common/pages/NotFound";
|
||||
import { Route, Switch } from "wouter";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import FriendPanel from "@/features/friends/FriendPanel";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import Home from "@/features/home/pages/Home";
|
||||
import Login from "@/features/auth/pages/Login";
|
||||
import Products from "@/features/products/pages/Products";
|
||||
import ProductDetail from "@/features/products/pages/ProductDetail";
|
||||
import Bounties from "@/features/bounties/pages/Bounties";
|
||||
import BountyDetail from "@/features/bounties/pages/BountyDetail";
|
||||
import Dashboard from "@/features/dashboard/pages/Dashboard";
|
||||
import Favorites from "@/features/favorites/pages/Favorites";
|
||||
import ProductComparison from "@/features/products/pages/ProductComparison";
|
||||
import Admin from "@/features/admin/pages/Admin";
|
||||
import Search from "@/features/search/pages/Search";
|
||||
import Settings from "@/features/settings/pages/Settings";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const Login = lazy(() => import("@/features/auth/pages/Login"));
|
||||
const Products = lazy(() => import("@/features/products/pages/Products"));
|
||||
const ProductDetail = lazy(() => import("@/features/products/pages/ProductDetail"));
|
||||
const Bounties = lazy(() => import("@/features/bounties/pages/Bounties"));
|
||||
const BountyDetail = lazy(() => import("@/features/bounties/pages/BountyDetail"));
|
||||
const Dashboard = lazy(() => import("@/features/dashboard/pages/Dashboard"));
|
||||
const Favorites = lazy(() => import("@/features/favorites/pages/Favorites"));
|
||||
const ProductComparison = lazy(() => import("@/features/products/pages/ProductComparison"));
|
||||
const Admin = lazy(() => import("@/features/admin/pages/Admin"));
|
||||
const Search = lazy(() => import("@/features/search/pages/Search"));
|
||||
const Settings = lazy(() => import("@/features/settings/pages/Settings"));
|
||||
const NotFound = lazy(() => import("@/features/common/pages/NotFound"));
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/products" component={Products} />
|
||||
<Route path="/products/:id" component={ProductDetail} />
|
||||
<Route path="/bounties" component={Bounties} />
|
||||
<Route path="/bounties/:id" component={BountyDetail} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/favorites" component={Favorites} />
|
||||
<Route path="/comparison" component={ProductComparison} />
|
||||
<Route path="/search" component={Search} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/products" component={Products} />
|
||||
<Route path="/products/:id" component={ProductDetail} />
|
||||
<Route path="/bounties" component={Bounties} />
|
||||
<Route path="/bounties/:id" component={BountyDetail} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/favorites" component={Favorites} />
|
||||
<Route path="/comparison" component={ProductComparison} />
|
||||
<Route path="/search" component={Search} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export function MobileNav() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [location] = useLocation();
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const { data: unreadCountData } = useUnreadNotificationCount();
|
||||
const { data: unreadCountData } = useUnreadNotificationCount({ enabled: isAuthenticated });
|
||||
const unreadCount = unreadCountData?.count || 0;
|
||||
|
||||
const navItems = [
|
||||
|
||||
@@ -13,7 +13,7 @@ interface NavbarProps {
|
||||
export function Navbar({ children, showLinks = true }: NavbarProps) {
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const [location] = useLocation();
|
||||
const { data: unreadCountData } = useUnreadNotificationCount();
|
||||
const { data: unreadCountData } = useUnreadNotificationCount({ enabled: isAuthenticated });
|
||||
const unreadCount = unreadCountData?.count || 0;
|
||||
|
||||
return (
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
Star,
|
||||
Trash2,
|
||||
Image as ImageIcon,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
@@ -121,15 +122,18 @@ export default function Dashboard() {
|
||||
const [isNewCategory, setIsNewCategory] = useState(false);
|
||||
const [newCategory, setNewCategory] = useState({ name: "", slug: "", description: "" });
|
||||
const [isCreatingCategory, setIsCreatingCategory] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(() =>
|
||||
new URLSearchParams(typeof window !== "undefined" ? window.location.search : "").get("tab") || "published"
|
||||
);
|
||||
|
||||
const { data: publishedData, isLoading: publishedLoading } = useMyPublishedBounties();
|
||||
const { data: acceptedData, isLoading: acceptedLoading } = useMyAcceptedBounties();
|
||||
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites();
|
||||
const { data: myProductsData, isLoading: myProductsLoading } = useMyProducts();
|
||||
const { data: categoriesData, isLoading: categoriesLoading } = useCategories();
|
||||
const { data: notificationsData, isLoading: notificationsLoading, refetch: refetchNotifications } = useNotifications();
|
||||
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites(undefined, { enabled: activeTab === "favorites" });
|
||||
const { data: myProductsData, isLoading: myProductsLoading } = useMyProducts(undefined, { enabled: activeTab === "products" });
|
||||
const { data: categoriesData, isLoading: categoriesLoading } = useCategories({ enabled: activeTab === "products" });
|
||||
const { data: notificationsData, isLoading: notificationsLoading, refetch: refetchNotifications } = useNotifications(undefined, { enabled: activeTab === "notifications" });
|
||||
const { data: unreadCountData } = useUnreadNotificationCount();
|
||||
const { data: notificationPreferences } = useNotificationPreferences();
|
||||
const { data: notificationPreferences } = useNotificationPreferences({ enabled: activeTab === "notifications" });
|
||||
const imageHeroId = "dashboard-product-image-hero";
|
||||
const moveImageToFront = (activeId: string) => {
|
||||
setImageFiles((prev) => {
|
||||
@@ -432,6 +436,24 @@ export default function Dashboard() {
|
||||
{user?.name || "用户"}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">{user?.email || "未设置邮箱"}</p>
|
||||
{user?.user_id && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">用户 ID:</span>
|
||||
<code className="rounded bg-muted px-2 py-0.5 text-sm font-mono">{user.user_id}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(user.user_id);
|
||||
toast.success("用户 ID 已复制");
|
||||
}}
|
||||
aria-label="复制用户 ID"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{user?.role === "admin" && (
|
||||
<Badge className="mt-2" variant="secondary">管理员</Badge>
|
||||
)}
|
||||
@@ -470,12 +492,25 @@ export default function Dashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue={new URLSearchParams(window.location.search).get('tab') || 'published'} className="space-y-6">
|
||||
{/* Tabs:按需加载,仅当前 Tab 请求数据 */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
setActiveTab(v);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", v);
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="products" className="gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
我的商品
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="published" className="gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
我发布的
|
||||
我的悬赏
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="accepted" className="gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
@@ -485,10 +520,6 @@ export default function Dashboard() {
|
||||
<Heart className="w-4 h-4" />
|
||||
我的收藏
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="products" className="gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
我的商品
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-2">
|
||||
<Bell className="w-4 h-4" />
|
||||
通知
|
||||
@@ -504,7 +535,7 @@ export default function Dashboard() {
|
||||
<TabsContent value="published">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle>我发布的悬赏</CardTitle>
|
||||
<CardTitle>我的悬赏</CardTitle>
|
||||
<CardDescription>管理您发布的所有悬赏任务</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, UserPlus, Users, X } from "lucide-react";
|
||||
import { Check, Inbox, Loader2, Search, UserPlus, Users, X } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
useAcceptFriendRequest,
|
||||
useCancelFriendRequest,
|
||||
useFriends,
|
||||
useIncomingFriendRequestCount,
|
||||
useIncomingFriendRequests,
|
||||
useMe,
|
||||
useOutgoingFriendRequests,
|
||||
@@ -31,15 +34,31 @@ function getFallbackText(name?: string | null, openId?: string | null) {
|
||||
return seed ? seed[0].toUpperCase() : "?";
|
||||
}
|
||||
|
||||
function FriendItemSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border/60 bg-card/50 px-4 py-3">
|
||||
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FriendPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||
|
||||
const meQuery = useMe();
|
||||
const friendsQuery = useFriends();
|
||||
const incomingQuery = useIncomingFriendRequests();
|
||||
const outgoingQuery = useOutgoingFriendRequests();
|
||||
// 仅打开面板时再请求好友/请求数据,减少首屏请求
|
||||
const friendsQuery = useFriends({ enabled: open });
|
||||
const incomingQuery = useIncomingFriendRequests({ enabled: open });
|
||||
const outgoingQuery = useOutgoingFriendRequests({ enabled: open });
|
||||
const incomingCountQuery = useIncomingFriendRequestCount({
|
||||
enabled: !open && !!meQuery.data,
|
||||
});
|
||||
const searchQuery = useSearchUsers(debouncedQuery, 10);
|
||||
|
||||
const sendRequest = useSendFriendRequest();
|
||||
@@ -51,18 +70,18 @@ export default function FriendPanel() {
|
||||
const incoming = incomingQuery.data ?? [];
|
||||
const outgoing = outgoingQuery.data ?? [];
|
||||
|
||||
const incomingCount = incoming.length;
|
||||
const incomingCount = open ? incoming.length : (incomingCountQuery.data?.count ?? 0);
|
||||
|
||||
const friendIdSet = useMemo(
|
||||
() => new Set(friends.map(item => item.user.id)),
|
||||
() => new Set(friends.map((item) => item.user.id)),
|
||||
[friends]
|
||||
);
|
||||
const outgoingIdSet = useMemo(
|
||||
() => new Set(outgoing.map(item => item.receiver.id)),
|
||||
() => new Set(outgoing.map((item) => item.receiver.id)),
|
||||
[outgoing]
|
||||
);
|
||||
const incomingIdSet = useMemo(
|
||||
() => new Set(incoming.map(item => item.requester.id)),
|
||||
() => new Set(incoming.map((item) => item.requester.id)),
|
||||
[incoming]
|
||||
);
|
||||
|
||||
@@ -71,66 +90,118 @@ export default function FriendPanel() {
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
className="fixed right-5 bottom-24 z-50 rounded-full shadow-lg"
|
||||
className="fixed right-5 bottom-24 z-50 rounded-full shadow-lg ring-2 ring-background/80 transition-all duration-200 hover:scale-110 hover:shadow-xl hover:ring-primary/30 active:scale-95"
|
||||
aria-label="打开好友面板"
|
||||
>
|
||||
<Users />
|
||||
<Users className="h-5 w-5" />
|
||||
{incomingCount > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-semibold text-white">
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-5 min-w-[1.25rem] animate-pulse items-center justify-center rounded-full bg-destructive px-1.5 text-[10px] font-bold text-destructive-foreground shadow-sm">
|
||||
{incomingCount > 99 ? "99+" : incomingCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle>好友</SheetTitle>
|
||||
<SheetContent className="flex flex-col p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b bg-gradient-to-b from-muted/40 to-transparent px-4 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-lg">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Users className="h-4 w-4" />
|
||||
</span>
|
||||
好友
|
||||
{friends.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 font-normal">
|
||||
{friends.length}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full flex-col gap-4 px-4 pb-4">
|
||||
<div className="flex flex-1 flex-col gap-0 overflow-hidden px-4 pb-4">
|
||||
{!meQuery.data && !meQuery.isLoading ? (
|
||||
<div className="pt-4 text-sm text-muted-foreground">
|
||||
登录后即可使用好友功能
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12 text-center">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Users className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
登录后即可使用好友功能
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="friends" className="flex-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="friends">好友</TabsTrigger>
|
||||
<TabsTrigger value="requests">请求</TabsTrigger>
|
||||
<TabsTrigger value="search">搜索</TabsTrigger>
|
||||
<Tabs defaultValue="friends" className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
<TabsList className="grid w-full grid-cols-3 bg-muted/60 p-1">
|
||||
<TabsTrigger
|
||||
value="friends"
|
||||
className="gap-1.5 rounded-md transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
好友
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="requests"
|
||||
className="relative gap-1.5 rounded-md transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Inbox className="h-3.5 w-3.5" />
|
||||
请求
|
||||
{incomingCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
|
||||
{incomingCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="search"
|
||||
className="gap-1.5 rounded-md transition-all data-[state=active]:bg-background data-[state=active]:shadow-sm"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
搜索
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="friends" className="flex-1">
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-2">
|
||||
{friends.length === 0 ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
暂无好友
|
||||
<TabsContent value="friends" className="mt-0 flex-1 overflow-hidden data-[state=inactive]:hidden">
|
||||
<ScrollArea className="h-full pr-2">
|
||||
{friendsQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<FriendItemSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : friends.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
|
||||
<div className="rounded-2xl bg-muted/50 p-6">
|
||||
<Users className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground">暂无好友</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
在「搜索」中添加好友,或等待对方接受你的请求
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{friends.map(item => (
|
||||
<div className="space-y-2">
|
||||
{friends.map((item) => (
|
||||
<div
|
||||
key={item.request_id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
className="group flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-card/50 px-4 py-3 transition-all duration-200 hover:border-border hover:bg-muted/30 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="h-10 w-10 ring-2 ring-background transition-transform duration-200 group-hover:ring-primary/20 group-hover:scale-105">
|
||||
<AvatarImage src={item.user.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{getFallbackText(item.user.name, item.user.open_id)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{item.user.name || item.user.open_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.user.open_id}
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.user.user_id || item.user.open_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="shrink-0 text-xs font-normal">
|
||||
已是好友
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -138,143 +209,198 @@ export default function FriendPanel() {
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests" className="flex-1">
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-2">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold text-muted-foreground">
|
||||
收到的请求
|
||||
<TabsContent value="requests" className="mt-0 flex-1 overflow-hidden data-[state=inactive]:hidden">
|
||||
<ScrollArea className="h-full pr-2">
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Inbox className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
收到的请求
|
||||
</span>
|
||||
{incoming.length > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{incoming.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{incoming.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
暂无请求
|
||||
{incomingQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<FriendItemSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : incoming.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 py-8 text-center text-sm text-muted-foreground">
|
||||
暂无新请求
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{incoming.map(item => (
|
||||
<div className="space-y-2">
|
||||
{incoming.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
className="group flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-card/50 px-4 py-3 transition-all duration-200 hover:border-border hover:bg-muted/30 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="h-10 w-10 ring-2 ring-background transition-transform duration-200 group-hover:scale-105">
|
||||
<AvatarImage src={item.requester.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{getFallbackText(
|
||||
item.requester.name,
|
||||
item.requester.open_id
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{item.requester.name || item.requester.open_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.requester.open_id}
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.requester.user_id || item.requester.open_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg transition-all hover:scale-105 active:scale-95"
|
||||
onClick={() => acceptRequest.mutate(item.id)}
|
||||
disabled={acceptRequest.isPending}
|
||||
aria-label="接受好友请求"
|
||||
>
|
||||
<Check />
|
||||
{acceptRequest.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg transition-all hover:scale-105 active:scale-95"
|
||||
onClick={() => rejectRequest.mutate(item.id)}
|
||||
disabled={rejectRequest.isPending}
|
||||
aria-label="拒绝好友请求"
|
||||
>
|
||||
<X />
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold text-muted-foreground">
|
||||
我发出的请求
|
||||
<section>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<UserPlus className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
我发出的请求
|
||||
</span>
|
||||
{outgoing.length > 0 && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{outgoing.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{outgoing.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
暂无请求
|
||||
{outgoingQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1].map((i) => (
|
||||
<FriendItemSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : outgoing.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 py-8 text-center text-sm text-muted-foreground">
|
||||
暂无发出的请求
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{outgoing.map(item => (
|
||||
<div className="space-y-2">
|
||||
{outgoing.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
className="group flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-card/50 px-4 py-3 transition-all duration-200 hover:border-border hover:bg-muted/30 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="h-10 w-10 ring-2 ring-background transition-transform duration-200 group-hover:scale-105">
|
||||
<AvatarImage src={item.receiver.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{getFallbackText(
|
||||
item.receiver.name,
|
||||
item.receiver.open_id
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{item.receiver.name || item.receiver.open_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.receiver.open_id}
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.receiver.user_id || item.receiver.open_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="shrink-0 transition-all hover:scale-105 active:scale-95"
|
||||
onClick={() => cancelRequest.mutate(item.id)}
|
||||
disabled={cancelRequest.isPending}
|
||||
>
|
||||
取消
|
||||
{cancelRequest.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
"取消"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="search" className="flex-1">
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
placeholder="输入用户名或账号搜索"
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
/>
|
||||
<TabsContent value="search" className="mt-0 flex-1 overflow-hidden data-[state=inactive]:hidden">
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="输入用户 ID、用户名或账号搜索"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-10 rounded-xl pl-9 pr-4 transition-all focus-visible:ring-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100vh-300px)] pr-2">
|
||||
<ScrollArea className="h-[calc(100%-3.5rem)] pr-2">
|
||||
{debouncedQuery.length === 0 ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
请输入关键词搜索用户
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
|
||||
<div className="rounded-2xl bg-muted/50 p-6">
|
||||
<Search className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请输入关键词搜索用户
|
||||
</p>
|
||||
</div>
|
||||
) : searchQuery.isLoading ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
搜索中...
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<FriendItemSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (searchQuery.data ?? []).length === 0 ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
没有找到匹配用户
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
|
||||
<div className="rounded-2xl bg-muted/50 p-6">
|
||||
<Users className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
没有找到匹配用户
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(searchQuery.data ?? []).map(user => {
|
||||
<div className="space-y-2">
|
||||
{(searchQuery.data ?? []).map((user) => {
|
||||
const isFriend = friendIdSet.has(user.id);
|
||||
const isOutgoing = outgoingIdSet.has(user.id);
|
||||
const isIncoming = incomingIdSet.has(user.id);
|
||||
@@ -290,31 +416,38 @@ export default function FriendPanel() {
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
className="group flex items-center justify-between gap-3 rounded-xl border border-border/60 bg-card/50 px-4 py-3 transition-all duration-200 hover:border-border hover:bg-muted/30 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Avatar className="h-10 w-10 ring-2 ring-background transition-transform duration-200 group-hover:ring-primary/20 group-hover:scale-105">
|
||||
<AvatarImage src={user.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{getFallbackText(user.name, user.open_id)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{user.name || user.open_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{user.open_id}
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{user.user_id || user.open_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={disabled ? "secondary" : "default"}
|
||||
onClick={() => sendRequest.mutate({ receiver_id: user.id })}
|
||||
className="shrink-0 gap-1.5 transition-all hover:scale-105 active:scale-95"
|
||||
onClick={() =>
|
||||
sendRequest.mutate({ receiver_id: user.id })
|
||||
}
|
||||
disabled={disabled || sendRequest.isPending}
|
||||
>
|
||||
<UserPlus />
|
||||
{sendRequest.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -111,27 +111,40 @@ export function useChangePassword() {
|
||||
|
||||
// ==================== Friends Hooks ====================
|
||||
|
||||
export function useFriends() {
|
||||
export function useFriends(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['friends'],
|
||||
queryFn: friendApi.list,
|
||||
staleTime: shortStaleTime,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useIncomingFriendRequests() {
|
||||
export function useIncomingFriendRequests(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['friends', 'requests', 'incoming'],
|
||||
queryFn: friendApi.incoming,
|
||||
staleTime: shortStaleTime,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOutgoingFriendRequests() {
|
||||
export function useOutgoingFriendRequests(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['friends', 'requests', 'outgoing'],
|
||||
queryFn: friendApi.outgoing,
|
||||
staleTime: shortStaleTime,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 仅请求待处理好友数量,用于角标(不拉完整列表) */
|
||||
export function useIncomingFriendRequestCount(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['friends', 'requests', 'incoming', 'count'],
|
||||
queryFn: () => friendApi.incomingCount(),
|
||||
staleTime: shortStaleTime,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,11 +205,12 @@ export function useSearchUsers(q: string, limit?: number) {
|
||||
|
||||
// ==================== Category Hooks ====================
|
||||
|
||||
export function useCategories() {
|
||||
export function useCategories(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: categoryApi.list,
|
||||
staleTime: staticStaleTime,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,7 +280,7 @@ export function useProductWithPrices(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useProductSearch(q: string, params?: { page?: number; category_id?: number; min_price?: number; max_price?: number; sort_by?: string }) {
|
||||
export function useProductSearch(q: string, params?: { page?: number; category_id?: number; user_id?: string; min_price?: number; max_price?: number; sort_by?: string }) {
|
||||
const debouncedQuery = useDebouncedValue(q, 300);
|
||||
return useQuery({
|
||||
queryKey: ['products', 'search', debouncedQuery, params],
|
||||
@@ -553,12 +567,13 @@ export function useReviewExtensionRequest() {
|
||||
|
||||
// ==================== Favorites Hooks ====================
|
||||
|
||||
export function useFavorites(params?: { tag_id?: number; page?: number }) {
|
||||
export function useFavorites(params?: { tag_id?: number; page?: number }, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['favorites', params],
|
||||
queryFn: () => favoriteApi.list(params),
|
||||
staleTime: shortStaleTime,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -711,12 +726,13 @@ export function useAllPriceMonitors(page?: number) {
|
||||
|
||||
// ==================== Notification Hooks ====================
|
||||
|
||||
export function useNotifications(params?: { is_read?: boolean; type?: string; start?: string; end?: string; page?: number }) {
|
||||
export function useNotifications(params?: { is_read?: boolean; type?: string; start?: string; end?: string; page?: number }, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', params],
|
||||
queryFn: () => notificationApi.list(params),
|
||||
staleTime: shortStaleTime,
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -730,11 +746,12 @@ export function useGlobalSearch(q: string, limit = 10) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadNotificationCount() {
|
||||
export function useUnreadNotificationCount(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: notificationApi.unreadCount,
|
||||
staleTime: shortStaleTime,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -760,11 +777,12 @@ export function useMarkAllNotificationsAsRead() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useNotificationPreferences() {
|
||||
export function useNotificationPreferences(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'preferences'],
|
||||
queryFn: notificationApi.getPreferences,
|
||||
staleTime: shortStaleTime,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -902,10 +920,11 @@ export function useUpdateAdminProductImages() {
|
||||
|
||||
// ==================== My Products Hooks ====================
|
||||
|
||||
export function useMyProducts(status?: string) {
|
||||
export function useMyProducts(status?: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['products', 'my', status],
|
||||
queryFn: () => productApi.myProducts(status),
|
||||
staleTime: shortStaleTime,
|
||||
enabled: options?.enabled !== false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Friend, FriendRequest, UserBrief } from "../types";
|
||||
export const friendApi = {
|
||||
list: () => api.get<Friend[]>("/friends/").then((r) => r.data),
|
||||
incoming: () => api.get<FriendRequest[]>("/friends/requests/incoming").then((r) => r.data),
|
||||
incomingCount: () => api.get<{ count: number }>("/friends/requests/incoming/count").then((r) => r.data),
|
||||
outgoing: () => api.get<FriendRequest[]>("/friends/requests/outgoing").then((r) => r.data),
|
||||
sendRequest: (data: { receiver_id: number }) =>
|
||||
api.post<FriendRequest>("/friends/requests", data).then((r) => r.data),
|
||||
|
||||
@@ -20,7 +20,7 @@ export const productApi = {
|
||||
},
|
||||
get: (id: number) => api.get<Product>(`/products/${id}`).then((r) => r.data),
|
||||
getWithPrices: (id: number) => api.get<ProductWithPrices>(`/products/${id}/with-prices`).then((r) => r.data),
|
||||
search: (params: { q: string; page?: number; category_id?: number; min_price?: number; max_price?: number; sort_by?: string }) =>
|
||||
search: (params: { q: string; page?: number; category_id?: number; user_id?: string; min_price?: number; max_price?: number; sort_by?: string }) =>
|
||||
api.get<PaginatedResponse<ProductWithPrices>>(
|
||||
"/products/search/",
|
||||
{ params, timeout: searchTimeout }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
open_id: string;
|
||||
/** 可分享的用户ID,用于好友搜索、商品搜索等 */
|
||||
user_id: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar: string | null;
|
||||
@@ -14,6 +16,8 @@ export interface User {
|
||||
export interface UserBrief {
|
||||
id: number;
|
||||
open_id: string;
|
||||
/** 可分享的用户ID */
|
||||
user_id: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar: string | null;
|
||||
|
||||
Reference in New Issue
Block a user