From 4e6a3e007c3aeea64c021c6f4e93bae7e21edc02 Mon Sep 17 00:00:00 2001 From: kurihada Date: Mon, 2 Mar 2026 12:06:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=8C=E7=A8=8B=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F=20+=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=EF=BC=88=E5=90=AB=E9=AB=98=E5=BE=B7=20POI=20?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 安装 @dnd-kit/core/sortable/utilities/modifiers - BlindboxPlan: 同天内拖拽排序(PointerSensor + TouchSensor,限垂直轴) - BlindboxPlan: 点击编辑弹出 sheet modal,支持改时间(type=time)、时长、活动名 - BlindboxPlan: POI 字段改为高德 inputtips 搜索下拉,选中自动填入名称/地址/坐标 - BlindboxPlan: 搜索传入房间坐标 location 参数,结果按距离排序 - BlindboxPlan: 跨天移动通过 select 立即生效 - plan route: 新增 update_plan action,支持 PATCH 保存修改后的 days - page.tsx: 新增 handlePlanDaysChange,乐观更新本地 state + 失败时回滚 --- package-lock.json | 72 +++++ package.json | 4 + src/app/api/blindbox/plan/route.ts | 22 +- src/app/blindbox/[code]/page.tsx | 23 ++ src/components/BlindboxPlan.tsx | 436 +++++++++++++++++++++++++---- 5 files changed, 502 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc6effb..2ec05e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "no-whatever", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/client": "^6.19.2", "bcryptjs": "^3.0.3", "canvas-confetti": "^1.9.4", @@ -568,6 +572,74 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", diff --git a/package.json b/package.json index 1a9e717..53ffe7d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@prisma/client": "^6.19.2", "bcryptjs": "^3.0.3", "canvas-confetti": "^1.9.4", diff --git a/src/app/api/blindbox/plan/route.ts b/src/app/api/blindbox/plan/route.ts index 061875d..51c6b68 100644 --- a/src/app/api/blindbox/plan/route.ts +++ b/src/app/api/blindbox/plan/route.ts @@ -75,7 +75,7 @@ function computeEndTime(planData: string, now: Date): Date | null { } export const PATCH = apiHandler(async (req) => { - const { planId, userId, action } = await req.json(); + const { planId, userId, action, days } = await req.json(); requireUserId(userId); if (!planId) throw new ApiError("planId 不能为空"); @@ -105,6 +105,26 @@ export const PATCH = apiHandler(async (req) => { return NextResponse.json({ ok: true }); } + if (act === "update_plan") { + if (plan.status !== "active" && plan.status !== "accepted") { + throw new ApiError("只能编辑进行中的计划", 400); + } + if (!Array.isArray(days) || days.length === 0) { + throw new ApiError("days 数据无效", 400); + } + const newPlanData = JSON.stringify({ days }); + await prisma.weekendPlan.update({ + where: { id: planId }, + data: { + planData: newPlanData, + ...(plan.status === "accepted" + ? { endTime: computeEndTime(newPlanData, new Date()) } + : {}), + }, + }); + return NextResponse.json({ ok: true }); + } + throw new ApiError("无效的操作", 400); }); diff --git a/src/app/blindbox/[code]/page.tsx b/src/app/blindbox/[code]/page.tsx index 36ee47e..cdcba38 100644 --- a/src/app/blindbox/[code]/page.tsx +++ b/src/app/blindbox/[code]/page.tsx @@ -606,6 +606,27 @@ export default function BlindboxRoomPage() { router.refresh(); }, [router]); + const handlePlanDaysChange = useCallback(async (newDays: WeekendPlanData[]) => { + if (!planId || !profile) return; + const prevDays = planDays; + setPlanDays(newDays); + if (planAccepted) { + setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev); + } + try { + const res = await fetch("/api/blindbox/plan", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ planId, userId: profile.id, action: "update_plan", days: newDays }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败"); + } catch (e) { + setPlanDays(prevDays); + if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev); + toast.show(e instanceof Error ? e.message : "保存失败"); + } + }, [planId, profile, planDays, planAccepted, toast]); + /** Non-creator: leave room (remove membership). Creator: delete room (after confirm). */ const handleLeaveOrDelete = async () => { if (!confirmLeave) { @@ -1090,6 +1111,8 @@ export default function BlindboxRoomPage() { days={planDays} accepted={planAccepted} regenerating={generating} + onDaysChange={handlePlanDaysChange} + location={room.lng != null && room.lat != null ? `${room.lng},${room.lat}` : undefined} onAccept={async () => { setPlanAccepted(true); fireConfetti(); diff --git a/src/components/BlindboxPlan.tsx b/src/components/BlindboxPlan.tsx index f3664fc..37e4528 100644 --- a/src/components/BlindboxPlan.tsx +++ b/src/components/BlindboxPlan.tsx @@ -12,10 +12,31 @@ import { ChevronRight, ChevronLeft, CornerDownLeft, + GripVertical, + Pencil, + X, + Loader2, } from "lucide-react"; +import { + DndContext, + DragEndEvent, + PointerSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CategoryBadge } from "@/components/BlindboxMyIdeas"; import Button from "@/components/Button"; -import type { WeekendPlanData } from "@/types"; +import Modal from "@/components/Modal"; +import type { WeekendPlanData, PlanItem } from "@/types"; interface BlindboxPlanProps { days: WeekendPlanData[]; @@ -25,6 +46,9 @@ interface BlindboxPlanProps { onBack: () => void; accepted?: boolean; regenerating?: boolean; + onDaysChange?: (newDays: WeekendPlanData[]) => void; + /** "lng,lat" 格式,用于 POI 搜索附近优先 */ + location?: string; } function guessCategory(activity: string): string | null { @@ -50,6 +74,187 @@ function formatDuration(minutes: number): string { return `${minutes}min`; } +interface PoiSuggestion { + id: string; + name: string; + district: string; + address: string; + lat: number; + lng: number; +} + +interface PoiSearchFieldProps { + poi: string; + address: string; + onSelect: (s: PoiSuggestion) => void; + location?: string; +} + +function PoiSearchField({ poi, address, onSelect, location }: PoiSearchFieldProps) { + const [query, setQuery] = useState(poi); + const [suggestions, setSuggestions] = useState([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const lastSelectedRef = useRef(poi); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (timerRef.current) clearTimeout(timerRef.current); + if (!query.trim() || query === lastSelectedRef.current) { + setSuggestions([]); + setOpen(false); + return; + } + timerRef.current = setTimeout(async () => { + setLoading(true); + try { + const params = new URLSearchParams({ keywords: query }); + if (location) params.set("location", location); + const res = await fetch(`/api/location/suggest?${params}`); + if (res.ok) { + const data: PoiSuggestion[] = await res.json(); + setSuggestions(data); + setOpen(data.length > 0); + } + } catch { /* ignore */ } + finally { setLoading(false); } + }, 400); + return () => { if (timerRef.current) clearTimeout(timerRef.current); }; + }, [query]); + + return ( +
+ 地点 +
+ setQuery(e.target.value)} + placeholder="搜索地点名称..." + className="h-9 w-full rounded-lg bg-elevated px-3 pr-8 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600" + /> + {loading && ( + + )} +
+ {open && suggestions.length > 0 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} + {address && !open && ( +

+ + {address} +

+ )} +
+ ); +} + +interface SortablePlanItemProps { + id: string; + item: PlanItem; + index: number; + canEdit: boolean; + onEdit: () => void; +} + +function SortablePlanItem({ id, item, canEdit, onEdit }: SortablePlanItemProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+
+
+ +
+
+ {canEdit && ( + + )} + {item.time} +
+
+ +

{item.activity}

+
+
+ + {item.poi} +
+ {item.address && ( +

{item.address}

+ )} +
+ + + {formatDuration(item.duration)} + + {item.lat !== 0 && item.lng !== 0 && ( + + + 导航 + + )} +
+ {item.reason && ( +

+ {item.reason} +

+ )} +
+ {canEdit && ( + + )} +
+
+
+ ); +} + export default function BlindboxPlan({ days, onAccept, @@ -58,17 +263,81 @@ export default function BlindboxPlan({ onBack, accepted, regenerating, + onDaysChange, + location, }: BlindboxPlanProps) { const [dayIndex, setDayIndex] = useState(0); const scrollRef = useRef(null); const currentDay = days[dayIndex]; const hasNext = dayIndex < days.length - 1; const hasPrev = dayIndex > 0; + const canEdit = !!onDaysChange; + + const [editingItem, setEditingItem] = useState<{ dayIndex: number; itemIndex: number } | null>(null); + const [draft, setDraft] = useState(null); useEffect(() => { scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, [dayIndex]); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), + ); + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + if (!over || active.id === over.id || !onDaysChange) return; + const items = days[dayIndex].items; + const ids = items.map((_, i) => `${dayIndex}-${i}`); + const oldIndex = ids.indexOf(String(active.id)); + const newIndex = ids.indexOf(String(over.id)); + if (oldIndex === -1 || newIndex === -1) return; + const newDays = days.map((day, di) => + di === dayIndex ? { ...day, items: arrayMove(day.items, oldIndex, newIndex) } : day, + ); + onDaysChange(newDays); + } + + function handleSaveDraft() { + if (!draft || !editingItem || !onDaysChange) return; + const newDays = days.map((day, di) => + di !== editingItem.dayIndex + ? day + : { + ...day, + items: day.items.map((item, ii) => + ii === editingItem.itemIndex ? draft : item, + ), + }, + ); + onDaysChange(newDays); + setEditingItem(null); + setDraft(null); + } + + // Cross-day move: immediately call onDaysChange + function handleMoveToDayIndex(targetDayIndex: number) { + if (!draft || !editingItem || !onDaysChange) return; + if (targetDayIndex === editingItem.dayIndex) return; + + const newDays = days.map((day, di) => { + if (di === editingItem.dayIndex) { + return { + ...day, + items: day.items.filter((_, ii) => ii !== editingItem.itemIndex), + }; + } + if (di === targetDayIndex) { + return { ...day, items: [...day.items, draft] }; + } + return day; + }); + onDaysChange(newDays); + setEditingItem(null); + setDraft(null); + } + if (!currentDay) return null; return ( @@ -129,60 +398,37 @@ export default function BlindboxPlan({ >
- {currentDay.items.map((item, i) => ( - + `${dayIndex}-${i}`)} + strategy={verticalListSortingStrategy} > -
-
-
- -
-
- {item.time} -
-
- -

{item.activity}

-
-
- - {item.poi} -
- {item.address && ( -

{item.address}

- )} -
- - - {formatDuration(item.duration)} - - {item.lat !== 0 && item.lng !== 0 && ( - - - 导航 - - )} -
- {item.reason && ( -

- {item.reason} -

- )} -
-
-
- - ))} + {currentDay.items.map((item, i) => ( + + { + setEditingItem({ dayIndex, itemIndex: i }); + setDraft({ ...item }); + }} + /> + + ))} + + @@ -265,6 +511,88 @@ export default function BlindboxPlan({ )}
+ + {/* Edit item modal */} + { setEditingItem(null); setDraft(null); }} variant="sheet"> + {draft && editingItem && ( +
+
+

编辑活动

+ +
+ +
+ + +
+ + +
+ + setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })} + /> + + {days.length > 1 && ( + + )} +
+ + +
+ )} +
); }