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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
+382
-54
@@ -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<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({
|
||||
days,
|
||||
onAccept,
|
||||
@@ -58,17 +263,81 @@ export default function BlindboxPlan({
|
||||
onBack,
|
||||
accepted,
|
||||
regenerating,
|
||||
onDaysChange,
|
||||
location,
|
||||
}: BlindboxPlanProps) {
|
||||
const [dayIndex, setDayIndex] = useState(0);
|
||||
const scrollRef = useRef<HTMLDivElement>(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<PlanItem | null>(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({
|
||||
>
|
||||
<div className="absolute left-[9px] top-2 bottom-2 w-px bg-purple-500/20" />
|
||||
|
||||
{currentDay.items.map((item, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="relative mb-4 last:mb-0"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.08 }}
|
||||
<DndContext
|
||||
key={dayIndex}
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{currentDay.items.map((item, i) => (
|
||||
<motion.div
|
||||
key={`${dayIndex}-${i}`}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.08 }}
|
||||
>
|
||||
<SortablePlanItem
|
||||
id={`${dayIndex}-${i}`}
|
||||
item={item}
|
||||
index={i}
|
||||
canEdit={canEdit}
|
||||
onEdit={() => {
|
||||
setEditingItem({ dayIndex, itemIndex: i });
|
||||
setDraft({ ...item });
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -265,6 +511,88 @@ export default function BlindboxPlan({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user