diff --git a/frontend/components.json b/frontend/components.json index 5a3c7506..9962451b 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -10,6 +10,7 @@ "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "@/components", "utils": "@/lib/utils", @@ -17,5 +18,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "iconLibrary": "lucide" -} \ No newline at end of file + "registries": { + "@animate-ui": "https://animate-ui.com/r/{name}.json" + } +} diff --git a/frontend/components/animate-ui/components/animate/avatar-group.tsx b/frontend/components/animate-ui/components/animate/avatar-group.tsx new file mode 100644 index 00000000..c99e43fd --- /dev/null +++ b/frontend/components/animate-ui/components/animate/avatar-group.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import * as motion from 'motion/react-client'; + +import { + AvatarGroup as AvatarGroupPrimitive, + AvatarGroupTooltip as AvatarGroupTooltipPrimitive, + AvatarGroupTooltipArrow as AvatarGroupTooltipArrowPrimitive, + type AvatarGroupProps as AvatarGroupPropsPrimitive, + type AvatarGroupTooltipProps as AvatarGroupTooltipPropsPrimitive, +} from '@/components/animate-ui/primitives/animate/avatar-group'; +import {cn} from '@/lib/utils'; + +type AvatarGroupProps = AvatarGroupPropsPrimitive; + +function AvatarGroup({ + className, + invertOverlap = true, + ...props +}: AvatarGroupProps) { + return ( + + ); +} + +type AvatarGroupTooltipProps = Omit< + AvatarGroupTooltipPropsPrimitive, + 'asChild' +> & { + children: React.ReactNode; + layout?: boolean | 'position' | 'size' | 'preserve-aspect'; +}; + +function AvatarGroupTooltip({ + className, + children, + layout = 'preserve-aspect', + ...props +}: AvatarGroupTooltipProps) { + return ( + + + {children} + + + + ); +} + +export { + AvatarGroup, + AvatarGroupTooltip, + type AvatarGroupProps, + type AvatarGroupTooltipProps, +}; diff --git a/frontend/components/animate-ui/primitives/animate/avatar-group.tsx b/frontend/components/animate-ui/primitives/animate/avatar-group.tsx new file mode 100644 index 00000000..d843d696 --- /dev/null +++ b/frontend/components/animate-ui/primitives/animate/avatar-group.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as React from 'react'; +import {HTMLMotionProps, motion, type Transition} from 'motion/react'; + +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, + TooltipArrow, + type TooltipProviderProps, + type TooltipProps, + type TooltipContentProps, + type TooltipArrowProps, +} from '@/components/animate-ui/primitives/animate/tooltip'; + +type AvatarProps = Omit, 'translate'> & { + children: React.ReactNode; + zIndex: number; + translate?: string | number; +} & Omit; + +function AvatarContainer({ + zIndex, + translate, + side, + sideOffset, + align, + alignOffset, + ...props +}: AvatarProps) { + return ( + + + + + + + + ); +} + +type AvatarGroupProps = Omit, 'translate'> & { + children: React.ReactElement[]; + invertOverlap?: boolean; + translate?: string | number; + transition?: Transition; + tooltipTransition?: Transition; +} & Omit & + Omit; + +function AvatarGroup({ + ref, + children, + id, + transition = {type: 'spring', stiffness: 300, damping: 17}, + invertOverlap = false, + translate = '-30%', + openDelay = 0, + closeDelay = 0, + side = 'top', + sideOffset = 25, + align = 'center', + alignOffset = 0, + tooltipTransition = {type: 'spring', stiffness: 300, damping: 35}, + style, + ...props +}: AvatarGroupProps) { + return ( + +
+ {children?.map((child, index) => ( + + {child} + + ))} +
+
+ ); +} + +type AvatarGroupTooltipProps = TooltipContentProps; + +function AvatarGroupTooltip(props: AvatarGroupTooltipProps) { + return ; +} + +type AvatarGroupTooltipArrowProps = TooltipArrowProps; + +function AvatarGroupTooltipArrow(props: AvatarGroupTooltipArrowProps) { + return ; +} + +export { + AvatarGroup, + AvatarGroupTooltip, + AvatarGroupTooltipArrow, + type AvatarGroupProps, + type AvatarGroupTooltipProps, + type AvatarGroupTooltipArrowProps, +}; diff --git a/frontend/components/animate-ui/primitives/animate/slot.tsx b/frontend/components/animate-ui/primitives/animate/slot.tsx new file mode 100644 index 00000000..d9b9ac45 --- /dev/null +++ b/frontend/components/animate-ui/primitives/animate/slot.tsx @@ -0,0 +1,96 @@ +'use client'; + +import * as React from 'react'; +import {motion, isMotionComponent, type HTMLMotionProps} from 'motion/react'; +import {cn} from '@/lib/utils'; + +type AnyProps = Record; + +type DOMMotionProps = Omit< + HTMLMotionProps, + 'ref' +> & { ref?: React.Ref }; + +type WithAsChild = + | (Base & { asChild: true; children: React.ReactElement }) + | (Base & { asChild?: false | undefined }); + +type SlotProps = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children?: any; +} & DOMMotionProps; + +function mergeRefs( + ...refs: (React.Ref | undefined)[] +): React.RefCallback { + return (node) => { + refs.forEach((ref) => { + if (!ref) return; + if (typeof ref === 'function') { + ref(node); + } else { + (ref as React.RefObject).current = node; + } + }); + }; +} + +function mergeProps( + childProps: AnyProps, + slotProps: DOMMotionProps, +): AnyProps { + const merged: AnyProps = {...childProps, ...slotProps}; + + if (childProps.className || slotProps.className) { + merged.className = cn( + childProps.className as string, + slotProps.className as string, + ); + } + + if (childProps.style || slotProps.style) { + merged.style = { + ...(childProps.style as React.CSSProperties), + ...(slotProps.style as React.CSSProperties), + }; + } + + return merged; +} + +function Slot({ + children, + ref, + ...props +}: SlotProps) { + const isAlreadyMotion = + typeof children.type === 'object' && + children.type !== null && + isMotionComponent(children.type); + + const Base = React.useMemo( + () => + isAlreadyMotion ? + (children.type as React.ElementType) : + motion.create(children.type as React.ElementType), + [isAlreadyMotion, children.type], + ); + + if (!React.isValidElement(children)) return null; + + const {ref: childRef, ...childProps} = children.props as AnyProps; + + const mergedProps = mergeProps(childProps, props); + + return ( + , ref)} /> + ); +} + +export { + Slot, + type SlotProps, + type WithAsChild, + type DOMMotionProps, + type AnyProps, +}; diff --git a/frontend/components/animate-ui/primitives/animate/tooltip.tsx b/frontend/components/animate-ui/primitives/animate/tooltip.tsx new file mode 100644 index 00000000..f01180eb --- /dev/null +++ b/frontend/components/animate-ui/primitives/animate/tooltip.tsx @@ -0,0 +1,573 @@ +'use client'; + +import * as React from 'react'; +import { + motion, + AnimatePresence, + LayoutGroup, + type Transition, + type HTMLMotionProps, +} from 'motion/react'; +import { + useFloating, + autoUpdate, + offset as floatingOffset, + flip, + shift, + arrow as floatingArrow, + FloatingPortal, + FloatingArrow, + type UseFloatingReturn, +} from '@floating-ui/react'; + +import {getStrictContext} from '@/lib/get-strict-context'; +import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot'; + +type Side = 'top' | 'bottom' | 'left' | 'right'; +type Align = 'start' | 'center' | 'end'; + +type TooltipData = { + contentProps: HTMLMotionProps<'div'>; + contentAsChild: boolean; + rect: DOMRect; + side: Side; + sideOffset: number; + align: Align; + alignOffset: number; + id: string; +}; + +type GlobalTooltipContextType = { + showTooltip: (data: TooltipData) => void; + hideTooltip: () => void; + hideImmediate: () => void; + currentTooltip: TooltipData | null; + transition: Transition; + globalId: string; + setReferenceEl: (el: HTMLElement | null) => void; + referenceElRef: React.RefObject; +}; + +const [GlobalTooltipProvider, useGlobalTooltip] = + getStrictContext('GlobalTooltipProvider'); + +type TooltipContextType = { + props: HTMLMotionProps<'div'>; + setProps: React.Dispatch>>; + asChild: boolean; + setAsChild: React.Dispatch>; + side: Side; + sideOffset: number; + align: Align; + alignOffset: number; + id: string; +}; + +const [LocalTooltipProvider, useTooltip] = getStrictContext( + 'LocalTooltipProvider', +); + +type TooltipPosition = { x: number; y: number }; + +function getResolvedSide(placement: Side | `${Side}-${Align}`) { + if (placement.includes('-')) { + return placement.split('-')[0] as Side; + } + return placement as Side; +} + +function initialFromSide(side: Side): Partial> { + if (side === 'top') return {y: 15}; + if (side === 'bottom') return {y: -15}; + if (side === 'left') return {x: 15}; + return {x: -15}; +} + +type TooltipProviderProps = { + children: React.ReactNode; + id?: string; + openDelay?: number; + closeDelay?: number; + transition?: Transition; +}; + +function TooltipProvider({ + children, + id, + openDelay = 700, + closeDelay = 300, + transition = {type: 'spring', stiffness: 300, damping: 35}, +}: TooltipProviderProps) { + const globalId = React.useId(); + const [currentTooltip, setCurrentTooltip] = + React.useState(null); + const timeoutRef = React.useRef(null); + const lastCloseTimeRef = React.useRef(0); + const referenceElRef = React.useRef(null); + + const showTooltip = React.useCallback( + (data: TooltipData) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (currentTooltip !== null) { + setCurrentTooltip(data); + return; + } + const now = Date.now(); + const delay = now - lastCloseTimeRef.current < closeDelay ? 0 : openDelay; + timeoutRef.current = window.setTimeout( + () => setCurrentTooltip(data), + delay, + ); + }, + [openDelay, closeDelay, currentTooltip], + ); + + const hideTooltip = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + setCurrentTooltip(null); + lastCloseTimeRef.current = Date.now(); + }, closeDelay); + }, [closeDelay]); + + const hideImmediate = React.useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + setCurrentTooltip(null); + lastCloseTimeRef.current = Date.now(); + }, []); + + const setReferenceEl = React.useCallback((el: HTMLElement | null) => { + referenceElRef.current = el; + }, []); + + React.useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') hideImmediate(); + }; + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('scroll', hideImmediate, true); + window.addEventListener('resize', hideImmediate, true); + return () => { + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('scroll', hideImmediate, true); + window.removeEventListener('resize', hideImmediate, true); + }; + }, [hideImmediate]); + + return ( + + {children} + + + ); +} + +type RenderedTooltipContextType = { + side: Side; + align: Align; + open: boolean; +}; + +const [RenderedTooltipProvider, useRenderedTooltip] = + getStrictContext('RenderedTooltipContext'); + +type FloatingContextType = { + context: UseFloatingReturn['context']; + arrowRef: React.RefObject; +}; + +const [FloatingProvider, useFloatingContext] = + getStrictContext('FloatingContext'); + +const MotionTooltipArrow = motion.create(FloatingArrow); + +type TooltipArrowProps = Omit< + React.ComponentProps, + 'context' +> & { + withTransition?: boolean; +}; + +function TooltipArrow({ + ref, + withTransition = true, + ...props +}: TooltipArrowProps) { + const {side, align, open} = useRenderedTooltip(); + const {context, arrowRef} = useFloatingContext(); + const {transition, globalId} = useGlobalTooltip(); + React.useImperativeHandle(ref, () => arrowRef.current as SVGSVGElement); + + const deg = {top: 0, right: 90, bottom: 180, left: -90}[side]; + + return ( + + ); +} + +type TooltipPortalProps = React.ComponentProps; + +function TooltipPortal(props: TooltipPortalProps) { + return ; +} + +function TooltipOverlay() { + const {currentTooltip, transition, globalId, referenceElRef} = + useGlobalTooltip(); + + const [rendered, setRendered] = React.useState<{ + data: TooltipData | null; + open: boolean; + }>({data: null, open: false}); + + const arrowRef = React.useRef(null); + + const side = rendered.data?.side ?? 'top'; + const align = rendered.data?.align ?? 'center'; + + const {refs, x, y, strategy, context, update} = useFloating({ + placement: align === 'center' ? side : `${side}-${align}`, + whileElementsMounted: autoUpdate, + middleware: [ + floatingOffset({ + mainAxis: rendered.data?.sideOffset ?? 0, + crossAxis: rendered.data?.alignOffset ?? 0, + }), + flip(), + shift({padding: 8}), + floatingArrow({element: arrowRef}), + ], + }); + + React.useEffect(() => { + if (currentTooltip) { + setRendered({data: currentTooltip, open: true}); + } else { + setRendered((p) => (p.data ? {...p, open: false} : p)); + } + }, [currentTooltip]); + + React.useLayoutEffect(() => { + if (referenceElRef.current) { + refs.setReference(referenceElRef.current); + update(); + } + }, [referenceElRef, refs, update, rendered.data]); + + const ready = x != null && y != null; + const Component = rendered.data?.contentAsChild ? Slot : motion.div; + const resolvedSide = getResolvedSide(context.placement); + + return ( + + {rendered.data && ready && ( + +
+ + + { + if (!rendered.open) { + setRendered({data: null, open: false}); + } + }} + transition={transition} + {...rendered.data.contentProps} + style={{ + position: 'relative', + ...(rendered.data.contentProps?.style || {}), + }} + /> + + +
+
+ )} +
+ ); +} + +type TooltipProps = { + children: React.ReactNode; + side?: Side; + sideOffset?: number; + align?: Align; + alignOffset?: number; +}; + +function Tooltip({ + children, + side = 'top', + sideOffset = 0, + align = 'center', + alignOffset = 0, +}: TooltipProps) { + const id = React.useId(); + const [props, setProps] = React.useState>({}); + const [asChild, setAsChild] = React.useState(false); + + return ( + + {children} + + ); +} + +type TooltipContentProps = WithAsChild>; + +function shallowEqualWithoutChildren( + a?: HTMLMotionProps<'div'>, + b?: HTMLMotionProps<'div'>, +) { + if (a === b) return true; + if (!a || !b) return false; + const keysA = Object.keys(a).filter((k) => k !== 'children'); + const keysB = Object.keys(b).filter((k) => k !== 'children'); + if (keysA.length !== keysB.length) return false; + for (const k of keysA) { + // @ts-expect-error index + if (a[k] !== b[k]) return false; + } + return true; +} + +function TooltipContent({asChild = false, ...props}: TooltipContentProps) { + const {setProps, setAsChild} = useTooltip(); + const lastPropsRef = React.useRef | undefined>( + undefined, + ); + + React.useEffect(() => { + if (!shallowEqualWithoutChildren(lastPropsRef.current, props)) { + lastPropsRef.current = props; + setProps(props); + } + }, [props, setProps]); + + React.useEffect(() => { + setAsChild(asChild); + }, [asChild, setAsChild]); + + return null; +} + +type TooltipTriggerProps = WithAsChild>; + +function TooltipTrigger({ + ref, + onMouseEnter, + onMouseLeave, + onFocus, + onBlur, + onPointerDown, + asChild = false, + ...props +}: TooltipTriggerProps) { + const { + props: contentProps, + asChild: contentAsChild, + side, + sideOffset, + align, + alignOffset, + id, + } = useTooltip(); + const { + showTooltip, + hideTooltip, + hideImmediate, + currentTooltip, + setReferenceEl, + } = useGlobalTooltip(); + + const triggerRef = React.useRef(null); + React.useImperativeHandle(ref, () => triggerRef.current as HTMLDivElement); + + const suppressNextFocusRef = React.useRef(false); + + const handleOpen = React.useCallback(() => { + if (!triggerRef.current) return; + setReferenceEl(triggerRef.current); + const rect = triggerRef.current.getBoundingClientRect(); + showTooltip({ + contentProps, + contentAsChild, + rect, + side, + sideOffset, + align, + alignOffset, + id, + }); + }, [ + showTooltip, + setReferenceEl, + contentProps, + contentAsChild, + side, + sideOffset, + align, + alignOffset, + id, + ]); + + const handlePointerDown = React.useCallback( + (e: React.PointerEvent) => { + onPointerDown?.(e); + if (currentTooltip?.id === id) { + suppressNextFocusRef.current = true; + hideImmediate(); + Promise.resolve().then(() => { + suppressNextFocusRef.current = false; + }); + } + }, + [onPointerDown, currentTooltip?.id, id, hideImmediate], + ); + + const handleMouseEnter = React.useCallback( + (e: React.MouseEvent) => { + onMouseEnter?.(e); + handleOpen(); + }, + [handleOpen, onMouseEnter], + ); + + const handleMouseLeave = React.useCallback( + (e: React.MouseEvent) => { + onMouseLeave?.(e); + hideTooltip(); + }, + [hideTooltip, onMouseLeave], + ); + + const handleFocus = React.useCallback( + (e: React.FocusEvent) => { + onFocus?.(e); + if (suppressNextFocusRef.current) return; + handleOpen(); + }, + [handleOpen, onFocus], + ); + + const handleBlur = React.useCallback( + (e: React.FocusEvent) => { + onBlur?.(e); + hideTooltip(); + }, + [hideTooltip, onBlur], + ); + + const Component = asChild ? Slot : motion.div; + + return ( + + ); +} + +export { + TooltipProvider, + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipArrow, + useGlobalTooltip, + useTooltip, + type TooltipProviderProps, + type TooltipProps, + type TooltipContentProps, + type TooltipTriggerProps, + type TooltipArrowProps, + type TooltipPosition, + type GlobalTooltipContextType, + type TooltipContextType, +}; diff --git a/frontend/components/common/receive/ReceiveContent.tsx b/frontend/components/common/receive/ReceiveContent.tsx index 70c5213a..4d7edc94 100644 --- a/frontend/components/common/receive/ReceiveContent.tsx +++ b/frontend/components/common/receive/ReceiveContent.tsx @@ -1,23 +1,25 @@ 'use client'; -import React, {useState, useEffect, useRef} from 'react'; +import Image from 'next/image'; +import {useState, useEffect, useRef} from 'react'; import Link from 'next/link'; import {useRouter, useSearchParams} from 'next/navigation'; import {toast} from 'sonner'; import {Button} from '@/components/ui/button'; import {Badge} from '@/components/ui/badge'; -import {CURRENCY_LABEL, TRUST_LEVEL_OPTIONS} from '@/components/common/project'; -import {ArrowLeftIcon, Copy, Tag, Gift, Clock, AlertCircle, Package, Coins, Loader2} from 'lucide-react'; +import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar'; +import {AvatarGroup, AvatarGroupTooltip} from '@/components/animate-ui/components/animate/avatar-group'; +import {CURRENCY_LABEL, DISTRIBUTION_MODE_NAMES, TRUST_LEVEL_OPTIONS} from '@/components/common/project'; +import {ArrowLeftIcon, Copy, Gift, Clock, AlertCircle, Package, Coins, Loader2, CalendarRange, Hash} from 'lucide-react'; import ContentRender from '@/components/common/markdown/ContentRender'; import {ReportButton} from '@/components/common/receive/ReportButton'; import {ReceiveVerify, ReceiveVerifyRef} from '@/components/common/receive/ReceiveVerify'; import services from '@/lib/services'; import {BasicUserInfo} from '@/lib/services/core'; import {GetProjectResponseData} from '@/lib/services/project'; -import {formatDateTimeWithSeconds, copyToClipboard} from '@/lib/utils'; +import {formatDate, formatDateTimeWithSeconds, copyToClipboard} from '@/lib/utils'; import {motion} from 'motion/react'; - - +import {Separator} from '@/components/ui/separator'; /** * 计算时间剩余显示文本 */ @@ -43,8 +45,6 @@ interface ReceiveButtonProps { project: GetProjectResponseData; user: BasicUserInfo | null; currentTime: Date; - hasReceived: boolean; - receivedContent: string | null; isVerifying: boolean; onReceive: () => void; } @@ -56,42 +56,9 @@ const ReceiveButton = ({ project, user, currentTime, - hasReceived, - receivedContent, isVerifying, onReceive, }: ReceiveButtonProps) => { - if (hasReceived && receivedContent) { - const contentItems = receivedContent.split('$\n*'); - - return ( -
-
分发内容
-
- {contentItems.map((item, index) => ( -
- - {item} - - -
- ))} -
-
- ); - } - const now = currentTime; const startTime = new Date(project.start_time); const endTime = new Date(project.end_time); @@ -99,7 +66,7 @@ const ReceiveButton = ({ if (now < startTime) { const timeRemaining = getTimeRemainingText(startTime, now); return ( - @@ -108,7 +75,7 @@ const ReceiveButton = ({ if (now > endTime) { return ( - @@ -117,7 +84,7 @@ const ReceiveButton = ({ if (project.available_items_count <= 0) { return ( - @@ -126,13 +93,22 @@ const ReceiveButton = ({ if (!user || user.trust_level < project.minimum_trust_level) { return ( - ); } + if (project.is_completed) { + return ( + + ); + } + const priceNum = Number(project.price || '0'); const isPaid = priceNum > 0; @@ -140,7 +116,7 @@ const ReceiveButton = ({ + + ))} + + + ); +} + +function VolunteerBanner() { + const [items, setItems] = useState([]); + + useEffect(() => { + let cancelled = false; + + const loadItems = async () => { + try { + const response = await fetch('/heart.json'); + if (!response.ok) return; + const data = await response.json() as VolunteerItem[]; + if (!cancelled && Array.isArray(data)) { + const shuffled = [...data].sort(() => Math.random() - 0.5); + setItems(shuffled.slice(0, 6)); + } + } catch { + if (!cancelled) { + setItems([]); + } + } + }; + + loadItems(); + + return () => { + cancelled = true; + }; + }, []); + + if (items.length === 0) { + return null; + } + + return ( +
+
LINUX DO 公益广告 (Beta)
+ + {items.map((item) => ( + window.open(item.detailUrl, '_blank')}> + + {item.name.slice(0, 1)} + + {item.name} +
+ {item.categoryName} · {item.name} + 走失时间:{item.lostDay} + 走失地点:{item.lostAddress} + {item.feature && ( + <> + 特征:{item.feature} + + )} +
+
+
+ ))} +
+
+ ); +} + /** * ReceiveContent 组件的 Props 接口 */ @@ -173,6 +241,19 @@ interface ReceiveContentProps { }; } +interface VolunteerItem { + name: string; + sex: string; + birthDay: string; + lostDay: string; + lostAddress: string; + lostHeight: string; + feature: string; + photoUrl: string; + detailUrl: string; + categoryName: string; +} + /** * 项目接收内容组件 */ @@ -314,6 +395,50 @@ export function ReceiveContent({data}: ReceiveContentProps) { }, [isAwaitingPayment, projectId]); const trustLevelConfig = TRUST_LEVEL_OPTIONS.find((option) => option.value === currentProject.minimum_trust_level); + const distributionModeName = DISTRIBUTION_MODE_NAMES[currentProject.distribution_type] || '分发项目'; + const startTime = new Date(currentProject.start_time); + const endTime = new Date(currentProject.end_time); + const receivedCount = Math.max(currentProject.total_items - currentProject.available_items_count, 0); + const projectContentItems = receivedContent?.split('$\n*').filter(Boolean) || []; + const statusLabel = currentProject.is_completed ? + '已完成' : + hasReceived ? + '已领取' : + currentTime < startTime ? + '未开始' : + currentTime > endTime ? + '已结束' : + currentProject.available_items_count <= 0 ? + '已领完' : + '进行中'; + const createdAtText = formatDate(currentProject.created_at); + const metaItems = [ + { + label: '分发人', + value: currentProject.creator_nickname || currentProject.creator_username, + href: `https://linux.do/u/${currentProject.creator_username}/summary`, + }, + { + label: '分发模式', + value: distributionModeName, + }, + { + label: '社区分数', + value: `${100 - currentProject.risk_level}`, + }, + { + label: '信任等级', + value: trustLevelConfig?.label || '无限制', + }, + { + label: '消耗 LDC', + value: Number(currentProject.price || '0') > 0 ? currentProject.price || '0' : '免费', + }, + { + label: 'IP 规则', + value: currentProject.allow_same_ip ? '允许同 IP' : '限制同 IP', + }, + ]; const containerVariants = { hidden: {opacity: 0}, @@ -353,7 +478,7 @@ export function ReceiveContent({data}: ReceiveContentProps) { return ( 返回 @@ -373,103 +498,181 @@ export function ReceiveContent({data}: ReceiveContentProps) { {isAwaitingPayment && ( 付款处理中,CDK 将在回调成功后自动发放。如长时间未到账,可刷新页面重试。 )} - -
-
{currentProject.name}
-
- {currentProject.tags && currentProject.tags.length > 0 ? ( - currentProject.tags.slice(0, 10).map((tag) => ( - - - {tag} + +
+
+
+
+ {currentProject.name} +
+
+ 共 {currentProject.total_items} 项内容 + / + 剩余 {currentProject.available_items_count} 项 + / + 创建于 {createdAtText} +
+
+ +
+ {currentProject.tags && currentProject.tags.length > 0 ? ( + currentProject.tags.slice(0, 10).map((tag) => ( + + + {tag} + + )) + ) : ( + + + 无标签 - )) + )} +
+
+ +
+
+ 项目摘要 +
+
+ {metaItems.map((item) => { + const content = ( +
+
+ {item.label} +
+
+ + {item.value} + {item.label === '消耗 LDC' && item.value !== '免费' ? ` ${CURRENCY_LABEL}` : ''} + +
+
+ ); + + return item.href ? ( + + {content} + + ) : ( +
+ {content} +
+ ); + })} +
+
+ +
+
项目内容
+ {projectContentItems.length > 0 ? ( +
+ {projectContentItems.map((item, index) => ( +
+ {item} +
+ ))} +
) : ( - - - 无标签 - +
+ 请先领取 CDK +
)}
-
- {formatDateTimeWithSeconds(currentProject.start_time)} - {formatDateTimeWithSeconds(currentProject.end_time)} -
-
-
-
剩余名额
-
{currentProject.available_items_count}
-
共 {currentProject.total_items} 个
-
-
+
- -
-
发布人
-
- - {currentProject.creator_nickname || currentProject.creator_username} - +
+
项目描述
+
-
-
最低用户分数
-
{100 - currentProject.risk_level}
-
-
-
信任等级
-
{trustLevelConfig?.label}
-
-
-
IP 限制
-
- {currentProject.allow_same_ip ? '允许同一 IP' : '限制同一 IP'} -
-
- - - - - - -

项目描述

-
- -
-
+
+
+
+
+
+
领取状态
+
+ {receivedCount} / {currentProject.total_items} +
+
+ 已领取 {receivedCount} 项,共 {currentProject.total_items} 项 +
+
+ + + {statusLabel} + +
+
+
+
开始时间
+
+ {formatDateTimeWithSeconds(currentProject.start_time)} +
+
+
+
结束时间
+
+ {formatDateTimeWithSeconds(currentProject.end_time)} +
+
+
+ + + +
+
领取操作
+ +
+ + {hasReceived && receivedContent && ( + <> + + + + )} + +
+ +
+
- -
-
- + +
diff --git a/frontend/components/common/receive/ReceiveMain.tsx b/frontend/components/common/receive/ReceiveMain.tsx index 28d550c5..0990c121 100644 --- a/frontend/components/common/receive/ReceiveMain.tsx +++ b/frontend/components/common/receive/ReceiveMain.tsx @@ -17,57 +17,66 @@ import {motion} from 'motion/react'; * 加载骨架屏组件 */ const LoadingSkeleton = () => ( -
+
{/* 返回按钮 */}
- +
- {/* 项目标题和剩余数量 */} -
-
- -
- {[1, 2, 3].map((i) => ( - - ))} +
+
+
+
+ + +
+
+ + +
+
+ {[1, 2, 3].map((i) => ( + + ))} +
- -
- -
- - - -
-
- {/* 项目信息卡片 */} -
- {[1, 2, 3, 4].map((i) => ( -
- - +
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))}
- ))} -
- {/* 领取按钮或领取成功显示 */} -
- -
+
+ +
+ + + + + +
+
+
- {/* 项目描述 */} -
- -
-
- - - - - +
+ + + +
+
+ + +
+
+ + +
+
@@ -166,13 +175,13 @@ export function ReceiveMain() { ) : error || !project ? ( - +
diff --git a/frontend/lib/get-strict-context.tsx b/frontend/lib/get-strict-context.tsx new file mode 100644 index 00000000..c341bdee --- /dev/null +++ b/frontend/lib/get-strict-context.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +function getStrictContext( + name?: string, +): readonly [ + ({ + value, + children, + }: { + value: T; + children?: React.ReactNode; + }) => React.JSX.Element, + () => T, +] { + const Context = React.createContext(undefined); + + const Provider = ({ + value, + children, + }: { + value: T; + children?: React.ReactNode; + }) => {children}; + + const useSafeContext = () => { + const ctx = React.useContext(Context); + if (ctx === undefined) { + throw new Error(`useContext must be used within ${name ?? 'a Provider'}`); + } + return ctx; + }; + + return [Provider, useSafeContext] as const; +} + +export {getStrictContext}; diff --git a/frontend/lib/services/project/types.ts b/frontend/lib/services/project/types.ts index 667d7a48..c17cf991 100644 --- a/frontend/lib/services/project/types.ts +++ b/frontend/lib/services/project/types.ts @@ -219,6 +219,14 @@ export interface GetProjectResponseData extends Project { creator_username: string; /** 创建者昵称 */ creator_nickname: string; + /** 项目是否已完成 */ + is_completed?: boolean; + /** 项目状态 */ + status?: number; + /** 举报数量 */ + report_count?: number; + /** 是否在探索页隐藏 */ + hide_from_explore?: boolean; /** 项目标签列表 */ tags: string[] | null; /** 可领取数量 */ diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 84e93bdb..dda25aae 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,6 +2,14 @@ import type {NextConfig} from 'next'; const nextConfig: NextConfig = { /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'so.baobeihuijia.com', + }, + ], + }, async rewrites() { return [ diff --git a/frontend/package.json b/frontend/package.json index bb0d232c..40a47aed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@floating-ui/react": "^0.27.19", "@hcaptcha/react-hcaptcha": "^1.12.0", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-avatar": "^1.1.10", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 79389487..e1a08db1 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.1.0) + '@floating-ui/react': + specifier: ^0.27.19 + version: 0.27.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@hcaptcha/react-hcaptcha': specifier: ^1.12.0 version: 1.12.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -299,15 +302,36 @@ packages: '@floating-ui/core@1.7.0': resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/dom@1.7.0': resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/react-dom@2.1.2': resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} @@ -3263,6 +3287,9 @@ packages: resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tailwind-merge@3.3.0: resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==} @@ -3550,17 +3577,42 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.9 + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + '@floating-ui/dom@1.7.0': dependencies: '@floating-ui/core': 1.7.0 '@floating-ui/utils': 0.2.9 + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + '@floating-ui/react-dom@2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@floating-ui/dom': 1.7.0 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@floating-ui/react-dom@2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/react@0.27.19(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.11 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.9': {} '@hcaptcha/loader@2.0.0': {} @@ -6982,6 +7034,8 @@ snapshots: dependencies: '@pkgr/core': 0.2.7 + tabbable@6.4.0: {} + tailwind-merge@3.3.0: {} tailwindcss@4.1.8: {} diff --git a/frontend/public/heart.json b/frontend/public/heart.json new file mode 100644 index 00000000..141eb91f --- /dev/null +++ b/frontend/public/heart.json @@ -0,0 +1,182 @@ +[ + { + "name": "秦玉美", + "sex": "女", + "birthDay": "1997-01-11", + "lostDay": "2000-02-03", + "lostAddress": "河北省,沧州市,河间市、北石槽乡、北大齐村", + "lostHeight": "85cm", + "feature": "爱说、头顶有两个旋", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2015/11/s_f5e193801379161c.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/35860.html", + "categoryName": "家寻宝贝" + }, + { + "name": "吴仕荣", + "sex": "男", + "birthDay": "1991-01-05", + "lostDay": "1991-10-30", + "lostAddress": "河北省,唐山市,迁西县,东莲花院乡柳沟峪村", + "lostHeight": "未知", + "feature": "男,身高185,体型微瘦,长脸,单眼皮", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2021/1/s_a74ba77ee952a9d9.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/6/125258.html", + "categoryName": "其他寻人" + }, + { + "name": "杨超", + "sex": "男", + "birthDay": "2001-03-30", + "lostDay": "2003-08-20", + "lostAddress": "宁夏吴忠市利通区东塔寺石佛四村八队", + "lostHeight": "80cm", + "feature": "左屁股有个小坑,后脑勺有个小伤疤。", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2009/12/s_246e430c9a13c63e.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/2406.html", + "categoryName": "家寻宝贝" + }, + { + "name": "贺广丽", + "sex": "女", + "birthDay": "1986-04-17", + "lostDay": "2002-01-29", + "lostAddress": "吉林省梅河口市吉乐乡卧龙村", + "lostHeight": "156cm", + "feature": "", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2012/1/s_50643d1bd946aa55.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/9053.html", + "categoryName": "家寻宝贝" + }, + { + "name": "许利", + "sex": "男", + "birthDay": "1988-10-14", + "lostDay": "2007-07-05", + "lostAddress": "浙江省,温州市,市辖区,小南门第一桥", + "lostHeight": "160cm", + "feature": "右胳膊上有一伤疤,缝了五六针线。", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2017/8/s_81fcae04c99b1e0e.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/6/64432.html", + "categoryName": "其他寻人" + }, + { + "name": "杨洋#", + "sex": "男", + "birthDay": "2005-08-15", + "lostDay": "2009-03-16", + "lostAddress": "云南镇雄赤水源广场", + "lostHeight": "90cm", + "feature": "圆脸型,大眼睛,语言不流利", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2009/4/s_314cddcc53bfa238.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/1038.html", + "categoryName": "家寻宝贝" + }, + { + "name": "黄朵", + "sex": "女", + "birthDay": "2000-02-03", + "lostDay": "2000-02-21", + "lostAddress": "江西省,萍乡市,芦溪县宣风镇茶垣村", + "lostHeight": "未知", + "feature": "黄朵发现的时候穿脏衣服。刚出生的,肚脐尚未愈合。圆脸。在她的喉咙一小疤痕。哭很多。在她的耳朵上一点点点头。\r\n", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2014/3/s_591f8ae1d0ec3d20.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/7/18514.html", + "categoryName": "海外寻亲" + }, + { + "name": "陈楠1", + "sex": "男", + "birthDay": "2002-08-22", + "lostDay": "2006-11-17", + "lostAddress": "重庆市沙坪坝区土弯", + "lostHeight": "未知", + "feature": "", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2009/11/s_1ac478664b98b687.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/2261.html", + "categoryName": "家寻宝贝" + }, + { + "name": "马喜琴", + "sex": "女", + "birthDay": "2002-10-03", + "lostDay": "2016-02-06", + "lostAddress": "甘肃省,定西市,渭源县,会川镇,罗家磨村,泉下社", + "lostHeight": "未知", + "feature": "她是个身体瘦瘦的,学生头发,身高1.5米", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2016/3/s_fd4188d4d1053a70.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/41923.html", + "categoryName": "家寻宝贝" + }, + { + "name": "徐义成", + "sex": "男", + "birthDay": "2012-12-07", + "lostDay": "2012-12-07", + "lostAddress": "贵州省,毕节市,七星关区,七星关区民政局", + "lostHeight": "未知", + "feature": "精神病人", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2012/12/s_29764241868f462f.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/6/12878.html", + "categoryName": "其他寻人" + }, + { + "name": "谭浩宇", + "sex": "男", + "birthDay": "2002-09-13", + "lostDay": "2013-09-07", + "lostAddress": "湖南省,郴州市, 嘉禾县车头镇沙平村", + "lostHeight": "2cm", + "feature": "鼻梁上有道痕迹", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2016/4/s_5891a60ad27072e2.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/42444.html", + "categoryName": "家寻宝贝" + }, + { + "name": "仵宇飞", + "sex": "男", + "birthDay": "2005-01-25", + "lostDay": "2019-08-19", + "lostAddress": "河北省,衡水市,冀州市魏屯镇", + "lostHeight": "2cm", + "feature": "圆脸不胖不瘦走路有点弯腰", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2020/2/s_8cd428d41e2930b4.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/108540.html", + "categoryName": "家寻宝贝" + }, + { + "name": "鲁梦洋", + "sex": "女", + "birthDay": "1995-01-03", + "lostDay": "2008-01-23", + "lostAddress": "天津市大港区", + "lostHeight": "158cm", + "feature": "上穿绿色短羽绒服,内穿偶合色毛衣,下穿牛仔裤,红色旅游鞋.", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2009/2/s_4bd8b6709d558298.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/940.html", + "categoryName": "家寻宝贝" + }, + { + "name": "池金凤", + "sex": "女", + "birthDay": "1989-02-20", + "lostDay": "2008-03-09", + "lostAddress": "浙江省,绍兴市,绍兴县,转移印花厂", + "lostHeight": "163cm", + "feature": "单眼皮柳叶眉身材适中", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2016/1/s_51cf6e44e381e1fb.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/6/38963.html", + "categoryName": "其他寻人" + }, + { + "name": "尹蓉", + "sex": "女", + "birthDay": "2006-10-16", + "lostDay": "2008-01-20", + "lostAddress": "云南省红河州个旧市鸡街镇东风路口", + "lostHeight": "未知", + "feature": "2008年1月20日在云南省红河州个旧市鸡街镇东风路口丢失,面颊右边有大拇指大小胎记,上穿土红色的衣服,下穿粉色的棉裤,脚穿黄色凉布鞋。", + "photoUrl": "https://so.baobeihuijia.com/bbhj/special/2009/12/s_86f6ff2513e3aac2.jpg", + "detailUrl": "https://www.baobeihuijia.com/bbhj/contents/3/2423.html", + "categoryName": "家寻宝贝" + } +] \ No newline at end of file