feat: 已接受契约持久化 — 保存/加载/自动展示

- PATCH /api/blindbox/plan: 接受契约时更新状态为 accepted
- GET /api/blindbox/plan: 查询房间内最近一次已接受的计划
- 进入房间时自动加载已接受计划并展示行程视图
- 修复整个周末想法不足时重复活动的问题(不足则只生成一天)
This commit is contained in:
2026-02-27 01:57:42 +08:00
parent 9c680ec11e
commit d8e42b860f
2 changed files with 85 additions and 8 deletions
+51 -5
View File
@@ -190,16 +190,18 @@ export const POST = apiHandler(async (req) => {
] ]
: [at]; : [at];
// Select ideas per day, avoiding duplicates across days when possible // Select ideas per day — skip extra days when ideas run out
const dayIdeas: TaggedIdea[][] = []; const dayIdeas: TaggedIdea[][] = [];
const usedIds = new Set<string>(); const usedIds = new Set<string>();
for (const dayConfig of dayConfigs) { for (const dayConfig of dayConfigs) {
const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id)); const remaining = taggedIdeas.filter((i) => !usedIds.has(i.id));
const pool = remaining.length >= 2 ? remaining : taggedIdeas; if (remaining.length < 2) break;
const selected = selectIdeasForSlots(pool, dayConfig.endHour - dayConfig.startHour); const selected = selectIdeasForSlots(remaining, dayConfig.endHour - dayConfig.startHour);
for (const idea of selected) usedIds.add(idea.id); for (const idea of selected) usedIds.add(idea.id);
dayIdeas.push(selected); dayIdeas.push(selected);
} }
// Trim to actual days generated (may be fewer than requested for "整个周末")
const actualDayConfigs = dayConfigs.slice(0, dayIdeas.length);
const allSelected = dayIdeas.flat(); const allSelected = dayIdeas.flat();
if (allSelected.length === 0) { if (allSelected.length === 0) {
@@ -260,7 +262,7 @@ export const POST = apiHandler(async (req) => {
// Generate schedule for each day (parallel AI calls) // Generate schedule for each day (parallel AI calls)
const schedules = await Promise.all( const schedules = await Promise.all(
dayConfigs.map((dayConfig, idx) => { actualDayConfigs.map((dayConfig, idx) => {
const ideas = dayIdeas[idx]; const ideas = dayIdeas[idx];
const ctx: ScheduleContext = { const ctx: ScheduleContext = {
ideas: ideas.map((i) => ({ ideas: ideas.map((i) => ({
@@ -282,7 +284,7 @@ export const POST = apiHandler(async (req) => {
const days = schedules const days = schedules
.map((schedule, idx) => .map((schedule, idx) =>
schedule schedule
? { date: dayConfigs[idx].date, items: schedule.items, summary: schedule.summary } ? { date: actualDayConfigs[idx].date, items: schedule.items, summary: schedule.summary }
: null, : null,
) )
.filter((d) => d !== null); .filter((d) => d !== null);
@@ -308,3 +310,47 @@ export const POST = apiHandler(async (req) => {
createdAt: plan.createdAt, createdAt: plan.createdAt,
}); });
}); });
export const PATCH = apiHandler(async (req) => {
const { planId, userId } = await req.json();
requireUserId(userId);
if (!planId) throw new ApiError("planId 不能为空");
const plan = await prisma.weekendPlan.findUnique({ where: { id: planId } });
if (!plan) throw new ApiError("计划不存在", 404);
if (plan.userId !== userId) throw new ApiError("只能接受自己的计划", 403);
await prisma.weekendPlan.update({
where: { id: planId },
data: { status: "accepted" },
});
return NextResponse.json({ ok: true });
});
export const GET = apiHandler(async (req) => {
const { searchParams } = new URL(req.url);
const roomId = searchParams.get("roomId");
const userId = searchParams.get("userId");
requireUserId(userId);
if (!roomId) throw new ApiError("roomId 不能为空");
const plan = await prisma.weekendPlan.findFirst({
where: { roomId, userId: userId!, status: "accepted" },
orderBy: { createdAt: "desc" },
select: { id: true, planData: true, createdAt: true },
});
if (!plan) return NextResponse.json({ plan: null });
const parsed = JSON.parse(plan.planData);
return NextResponse.json({
plan: {
id: plan.id,
days: parsed.days,
createdAt: plan.createdAt,
},
});
});
+34 -3
View File
@@ -71,6 +71,7 @@ export default function BlindboxRoomPage() {
const [confirmLeave, setConfirmLeave] = useState(false); const [confirmLeave, setConfirmLeave] = useState(false);
const [leaving, setLeaving] = useState(false); const [leaving, setLeaving] = useState(false);
const [locating, setLocating] = useState(false); const [locating, setLocating] = useState(false);
const [planId, setPlanId] = useState<string | null>(null);
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]); const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
const [planAccepted, setPlanAccepted] = useState(false); const [planAccepted, setPlanAccepted] = useState(false);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
@@ -136,9 +137,28 @@ export default function BlindboxRoomPage() {
} catch { /* ignore */ } } catch { /* ignore */ }
}, [room]); }, [room]);
const fetchAcceptedPlan = useCallback(async () => {
const p = getCachedProfile();
if (!room || !p) return;
try {
const res = await fetch(`/api/blindbox/plan?roomId=${room.id}&userId=${p.id}`);
if (!res.ok) return;
const data = await res.json();
if (data.plan) {
setPlanId(data.plan.id);
setPlanDays(data.plan.days);
setPlanAccepted(true);
setPhase("plan_reveal");
}
} catch { /* ignore */ }
}, [room]);
useEffect(() => { useEffect(() => {
if (isMember && room) fetchIdeas(); if (isMember && room) {
}, [isMember, room, fetchIdeas]); fetchIdeas();
fetchAcceptedPlan();
}
}, [isMember, room, fetchIdeas, fetchAcceptedPlan]);
useEffect(() => { useEffect(() => {
if (isMember && inputRef.current) { if (isMember && inputRef.current) {
@@ -212,6 +232,7 @@ export default function BlindboxRoomPage() {
throw new Error(data.error || "生成失败"); throw new Error(data.error || "生成失败");
} }
const data = await res.json(); const data = await res.json();
setPlanId(data.id);
setPlanDays(data.days); setPlanDays(data.days);
setPlanAccepted(false); setPlanAccepted(false);
setPhase("plan_reveal"); setPhase("plan_reveal");
@@ -782,9 +803,18 @@ export default function BlindboxRoomPage() {
days={planDays} days={planDays}
accepted={planAccepted} accepted={planAccepted}
regenerating={generating} regenerating={generating}
onAccept={() => { onAccept={async () => {
setPlanAccepted(true); setPlanAccepted(true);
fireConfetti(); fireConfetti();
if (planId && profile) {
try {
await fetch("/api/blindbox/plan", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planId, userId: profile.id }),
});
} catch { /* best-effort */ }
}
}} }}
onRegenerate={() => { onRegenerate={() => {
setPhase("time_select"); setPhase("time_select");
@@ -792,6 +822,7 @@ export default function BlindboxRoomPage() {
onShare={() => setShowPlanShareCard(true)} onShare={() => setShowPlanShareCard(true)}
onBack={() => { onBack={() => {
setPhase("pool"); setPhase("pool");
setPlanId(null);
setPlanDays([]); setPlanDays([]);
setPlanAccepted(false); setPlanAccepted(false);
}} }}