This commit is contained in:
ddrwode
2026-02-02 13:48:11 +08:00
parent 407471c1ff
commit fc0679b199
20 changed files with 6082 additions and 167 deletions

View File

@@ -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

View 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 = [
]

View File

@@ -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

View File

@@ -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 = (

View File

@@ -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))]
)

View 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),
]

View File

@@ -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

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -1,25 +1,37 @@
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 (
<Suspense fallback={<PageLoader />}>
<Switch>
<Route path="/" component={Home} />
<Route path="/login" component={Login} />
@@ -36,6 +48,7 @@ function Router() {
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>
</Suspense>
);
}

View File

@@ -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 = [

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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">
<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="输入用户名或账号搜索"
placeholder="输入用户 ID、用户名或账号搜索"
value={query}
onChange={event => setQuery(event.target.value)}
onChange={(e) => setQuery(e.target.value)}
className="h-10 rounded-xl pl-9 pr-4 transition-all focus-visible:ring-2"
/>
</div>
<ScrollArea className="h-[calc(100vh-300px)] pr-2">
</div>
<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>

View File

@@ -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,
});
}

View File

@@ -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),

View File

@@ -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 }

View File

@@ -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;