feat: 行程活动拖拽排序 + 编辑表单(含高德 POI 搜索)
- 安装 @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 + 失败时回滚
This commit is contained in:
Generated
+72
@@ -8,6 +8,10 @@
|
|||||||
"name": "no-whatever",
|
"name": "no-whatever",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"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",
|
"@prisma/client": "^6.19.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
@@ -568,6 +572,74 @@
|
|||||||
"node": ">=20.19.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@prisma/client": "^6.19.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ function computeEndTime(planData: string, now: Date): Date | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PATCH = apiHandler(async (req) => {
|
export const PATCH = apiHandler(async (req) => {
|
||||||
const { planId, userId, action } = await req.json();
|
const { planId, userId, action, days } = await req.json();
|
||||||
requireUserId(userId);
|
requireUserId(userId);
|
||||||
if (!planId) throw new ApiError("planId 不能为空");
|
if (!planId) throw new ApiError("planId 不能为空");
|
||||||
|
|
||||||
@@ -105,6 +105,26 @@ export const PATCH = apiHandler(async (req) => {
|
|||||||
return NextResponse.json({ ok: true });
|
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);
|
throw new ApiError("无效的操作", 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -606,6 +606,27 @@ export default function BlindboxRoomPage() {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}, [router]);
|
}, [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). */
|
/** Non-creator: leave room (remove membership). Creator: delete room (after confirm). */
|
||||||
const handleLeaveOrDelete = async () => {
|
const handleLeaveOrDelete = async () => {
|
||||||
if (!confirmLeave) {
|
if (!confirmLeave) {
|
||||||
@@ -1090,6 +1111,8 @@ export default function BlindboxRoomPage() {
|
|||||||
days={planDays}
|
days={planDays}
|
||||||
accepted={planAccepted}
|
accepted={planAccepted}
|
||||||
regenerating={generating}
|
regenerating={generating}
|
||||||
|
onDaysChange={handlePlanDaysChange}
|
||||||
|
location={room.lng != null && room.lat != null ? `${room.lng},${room.lat}` : undefined}
|
||||||
onAccept={async () => {
|
onAccept={async () => {
|
||||||
setPlanAccepted(true);
|
setPlanAccepted(true);
|
||||||
fireConfetti();
|
fireConfetti();
|
||||||
|
|||||||
+382
-54
@@ -12,10 +12,31 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
|
GripVertical,
|
||||||
|
Pencil,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} 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 { CategoryBadge } from "@/components/BlindboxMyIdeas";
|
||||||
import Button from "@/components/Button";
|
import Button from "@/components/Button";
|
||||||
import type { WeekendPlanData } from "@/types";
|
import Modal from "@/components/Modal";
|
||||||
|
import type { WeekendPlanData, PlanItem } from "@/types";
|
||||||
|
|
||||||
interface BlindboxPlanProps {
|
interface BlindboxPlanProps {
|
||||||
days: WeekendPlanData[];
|
days: WeekendPlanData[];
|
||||||
@@ -25,6 +46,9 @@ interface BlindboxPlanProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
accepted?: boolean;
|
accepted?: boolean;
|
||||||
regenerating?: boolean;
|
regenerating?: boolean;
|
||||||
|
onDaysChange?: (newDays: WeekendPlanData[]) => void;
|
||||||
|
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
|
||||||
|
location?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function guessCategory(activity: string): string | null {
|
function guessCategory(activity: string): string | null {
|
||||||
@@ -50,6 +74,187 @@ function formatDuration(minutes: number): string {
|
|||||||
return `${minutes}min`;
|
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<PoiSuggestion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const lastSelectedRef = useRef(poi);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-medium text-muted">地点</span>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<Loader2 size={14} className="absolute right-2.5 top-2.5 animate-spin text-muted" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{open && suggestions.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-lg bg-elevated ring-1 ring-border">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
lastSelectedRef.current = s.name;
|
||||||
|
setQuery(s.name);
|
||||||
|
setOpen(false);
|
||||||
|
setSuggestions([]);
|
||||||
|
onSelect(s);
|
||||||
|
}}
|
||||||
|
className="flex w-full flex-col border-b border-border/40 px-3 py-2 text-left last:border-0 active:bg-purple-600/10"
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium text-foreground">{s.name}</span>
|
||||||
|
<span className="truncate text-[10px] text-muted">{s.district} {s.address}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{address && !open && (
|
||||||
|
<p className="flex items-center gap-1 text-[10px] text-dim">
|
||||||
|
<MapPin size={9} className="shrink-0" />
|
||||||
|
{address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div ref={setNodeRef} style={style} className="relative mb-4 last:mb-0">
|
||||||
|
<div className="absolute -left-6 top-3 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-purple-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-surface/80 p-3.5 ring-1 ring-border/80">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="mt-1 shrink-0 touch-none cursor-grab text-muted/40 active:cursor-grabbing active:text-muted"
|
||||||
|
aria-label="拖拽排序"
|
||||||
|
>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className="mt-0.5 text-sm font-black text-purple-400">{item.time}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CategoryBadge category={guessCategory(item.activity)} />
|
||||||
|
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-muted">
|
||||||
|
<MapPin size={10} className="shrink-0" />
|
||||||
|
<span className="truncate">{item.poi}</span>
|
||||||
|
</div>
|
||||||
|
{item.address && (
|
||||||
|
<p className="mt-0.5 truncate text-[10px] text-dim">{item.address}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-dim">
|
||||||
|
<Clock size={9} />
|
||||||
|
{formatDuration(item.duration)}
|
||||||
|
</span>
|
||||||
|
{item.lat !== 0 && item.lng !== 0 && (
|
||||||
|
<a
|
||||||
|
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
|
||||||
|
>
|
||||||
|
<Navigation size={9} />
|
||||||
|
导航
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.reason && (
|
||||||
|
<p className="mt-1.5 text-[10px] leading-relaxed text-dim italic">
|
||||||
|
{item.reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={onEdit}
|
||||||
|
className="mt-0.5 shrink-0 text-muted/40 active:text-purple-400"
|
||||||
|
aria-label="编辑"
|
||||||
|
>
|
||||||
|
<Pencil size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function BlindboxPlan({
|
export default function BlindboxPlan({
|
||||||
days,
|
days,
|
||||||
onAccept,
|
onAccept,
|
||||||
@@ -58,17 +263,81 @@ export default function BlindboxPlan({
|
|||||||
onBack,
|
onBack,
|
||||||
accepted,
|
accepted,
|
||||||
regenerating,
|
regenerating,
|
||||||
|
onDaysChange,
|
||||||
|
location,
|
||||||
}: BlindboxPlanProps) {
|
}: BlindboxPlanProps) {
|
||||||
const [dayIndex, setDayIndex] = useState(0);
|
const [dayIndex, setDayIndex] = useState(0);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const currentDay = days[dayIndex];
|
const currentDay = days[dayIndex];
|
||||||
const hasNext = dayIndex < days.length - 1;
|
const hasNext = dayIndex < days.length - 1;
|
||||||
const hasPrev = dayIndex > 0;
|
const hasPrev = dayIndex > 0;
|
||||||
|
const canEdit = !!onDaysChange;
|
||||||
|
|
||||||
|
const [editingItem, setEditingItem] = useState<{ dayIndex: number; itemIndex: number } | null>(null);
|
||||||
|
const [draft, setDraft] = useState<PlanItem | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}, [dayIndex]);
|
}, [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;
|
if (!currentDay) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,60 +398,37 @@ export default function BlindboxPlan({
|
|||||||
>
|
>
|
||||||
<div className="absolute left-[9px] top-2 bottom-2 w-px bg-purple-500/20" />
|
<div className="absolute left-[9px] top-2 bottom-2 w-px bg-purple-500/20" />
|
||||||
|
|
||||||
{currentDay.items.map((item, i) => (
|
<DndContext
|
||||||
<motion.div
|
key={dayIndex}
|
||||||
key={i}
|
sensors={sensors}
|
||||||
className="relative mb-4 last:mb-0"
|
modifiers={[restrictToVerticalAxis]}
|
||||||
initial={{ opacity: 0, x: -10 }}
|
onDragEnd={handleDragEnd}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
>
|
||||||
transition={{ delay: 0.1 + i * 0.08 }}
|
<SortableContext
|
||||||
|
items={currentDay.items.map((_, i) => `${dayIndex}-${i}`)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="absolute -left-6 top-3 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
|
{currentDay.items.map((item, i) => (
|
||||||
<div className="h-2 w-2 rounded-full bg-purple-400" />
|
<motion.div
|
||||||
</div>
|
key={`${dayIndex}-${i}`}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
<div className="rounded-xl bg-surface/80 p-3.5 ring-1 ring-border/80">
|
animate={{ opacity: 1, x: 0 }}
|
||||||
<div className="flex items-start gap-2">
|
transition={{ delay: 0.1 + i * 0.08 }}
|
||||||
<span className="mt-0.5 text-sm font-black text-purple-400">{item.time}</span>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<SortablePlanItem
|
||||||
<div className="flex items-center gap-1.5">
|
id={`${dayIndex}-${i}`}
|
||||||
<CategoryBadge category={guessCategory(item.activity)} />
|
item={item}
|
||||||
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
|
index={i}
|
||||||
</div>
|
canEdit={canEdit}
|
||||||
<div className="mt-1.5 flex items-center gap-1 text-[11px] text-muted">
|
onEdit={() => {
|
||||||
<MapPin size={10} className="shrink-0" />
|
setEditingItem({ dayIndex, itemIndex: i });
|
||||||
<span className="truncate">{item.poi}</span>
|
setDraft({ ...item });
|
||||||
</div>
|
}}
|
||||||
{item.address && (
|
/>
|
||||||
<p className="mt-0.5 truncate text-[10px] text-dim">{item.address}</p>
|
</motion.div>
|
||||||
)}
|
))}
|
||||||
<div className="mt-2 flex items-center gap-3">
|
</SortableContext>
|
||||||
<span className="flex items-center gap-1 text-[10px] text-dim">
|
</DndContext>
|
||||||
<Clock size={9} />
|
|
||||||
{formatDuration(item.duration)}
|
|
||||||
</span>
|
|
||||||
{item.lat !== 0 && item.lng !== 0 && (
|
|
||||||
<a
|
|
||||||
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
|
|
||||||
>
|
|
||||||
<Navigation size={9} />
|
|
||||||
导航
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{item.reason && (
|
|
||||||
<p className="mt-1.5 text-[10px] leading-relaxed text-dim italic">
|
|
||||||
{item.reason}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
@@ -265,6 +511,88 @@ export default function BlindboxPlan({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit item modal */}
|
||||||
|
<Modal open={!!editingItem && !!draft} onClose={() => { setEditingItem(null); setDraft(null); }} variant="sheet">
|
||||||
|
{draft && editingItem && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-bold text-heading">编辑活动</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingItem(null); setDraft(null); }}
|
||||||
|
className="text-muted active:text-foreground"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-medium text-muted">活动名称</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={draft.activity}
|
||||||
|
onChange={(e) => setDraft({ ...draft, activity: e.target.value })}
|
||||||
|
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<label className="flex flex-1 flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-medium text-muted">时间</span>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={draft.time}
|
||||||
|
onChange={(e) => setDraft({ ...draft, time: e.target.value })}
|
||||||
|
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-1 flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-medium text-muted">时长(分钟)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step={15}
|
||||||
|
min={15}
|
||||||
|
value={draft.duration}
|
||||||
|
onChange={(e) => setDraft({ ...draft, duration: Number(e.target.value) })}
|
||||||
|
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PoiSearchField
|
||||||
|
key={`${editingItem.dayIndex}-${editingItem.itemIndex}`}
|
||||||
|
poi={draft.poi}
|
||||||
|
address={draft.address}
|
||||||
|
location={location}
|
||||||
|
onSelect={(s) => setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{days.length > 1 && (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-medium text-muted">移动到其他天</span>
|
||||||
|
<select
|
||||||
|
value={editingItem.dayIndex}
|
||||||
|
onChange={(e) => handleMoveToDayIndex(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||||
|
>
|
||||||
|
{days.map((day, i) => (
|
||||||
|
<option key={day.date} value={i}>
|
||||||
|
{day.date}
|
||||||
|
{i === editingItem.dayIndex ? "(当前)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSaveDraft} variant="purple" shape="pill">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user