@lmy54321/design-system 1.1.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,697 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import { cn } from "@lmy54321/design-system";
4
+ import { TopToolbar } from "@lmy54321/design-system";
5
+ import { SegmentedControl } from "@lmy54321/design-system";
6
+ import { Btn } from "@lmy54321/design-system";
7
+ import { Tag } from "@lmy54321/design-system";
8
+ import { StatGrid } from "@lmy54321/design-system";
9
+ import { NotificationBar } from "@lmy54321/design-system";
10
+ import { Toast } from "@lmy54321/design-system";
11
+ import { BottomSheet } from "@lmy54321/design-system";
12
+ import { IconFont } from "@lmy54321/design-system";
13
+ import { TencentMap } from "@lmy54321/design-system";
14
+ import { DraggablePanel, DRAWER_STATES } from "@lmy54321/design-system";
15
+
16
+ /* ══════════════════════════════════════════
17
+ 数据
18
+ ══════════════════════════════════════════ */
19
+
20
+ const MEMBER_COLORS = ["#367BF6", "#FF6B35", "#22C55E", "#A855F7", "#F59E0B", "#EC4899"];
21
+
22
+ interface TeamMember {
23
+ id: string;
24
+ name: string;
25
+ avatar: string;
26
+ color: string;
27
+ status: "moving" | "arrived" | "offline";
28
+ distance: string;
29
+ eta: string;
30
+ lat: number;
31
+ lng: number;
32
+ }
33
+
34
+ const teamMembers: TeamMember[] = [
35
+ { id: "1", name: "张伟", avatar: "user", color: MEMBER_COLORS[0], status: "moving", distance: "2.3km", eta: "8分钟", lat: 40, lng: 48 },
36
+ { id: "2", name: "李娜", avatar: "user", color: MEMBER_COLORS[1], status: "arrived", distance: "0m", eta: "已到达", lat: 55, lng: 60 },
37
+ { id: "3", name: "王磊", avatar: "user", color: MEMBER_COLORS[2], status: "moving", distance: "5.1km", eta: "15分钟", lat: 30, lng: 35 },
38
+ { id: "4", name: "赵敏", avatar: "user", color: MEMBER_COLORS[3], status: "moving", distance: "1.8km", eta: "6分钟", lat: 45, lng: 70 },
39
+ { id: "5", name: "陈强", avatar: "user", color: MEMBER_COLORS[4], status: "offline", distance: "--", eta: "离线", lat: 65, lng: 25 },
40
+ { id: "6", name: "刘芳", avatar: "user", color: MEMBER_COLORS[5], status: "moving", distance: "3.6km", eta: "12分钟", lat: 25, lng: 55 },
41
+ ];
42
+
43
+ interface Itinerary {
44
+ id: string;
45
+ order: number;
46
+ name: string;
47
+ address: string;
48
+ time: string;
49
+ status: "completed" | "current" | "upcoming";
50
+ type: "food" | "scenic" | "hotel" | "activity";
51
+ }
52
+
53
+ const itineraryItems: Itinerary[] = [
54
+ { id: "1", order: 1, name: "望京集合点", address: "望京SOHO T1 大堂", time: "09:00", status: "completed", type: "activity" },
55
+ { id: "2", order: 2, name: "古北水镇", address: "北京市密云区古北口镇司马台村", time: "10:30", status: "current", type: "scenic" },
56
+ { id: "3", order: 3, name: "司马台长城", address: "北京市密云区古北口镇", time: "14:00", status: "upcoming", type: "scenic" },
57
+ { id: "4", order: 4, name: "烧烤农家乐", address: "古北口镇河西村18号", time: "17:00", status: "upcoming", type: "food" },
58
+ { id: "5", order: 5, name: "水镇大酒店", address: "古北水镇景区内", time: "19:30", status: "upcoming", type: "hotel" },
59
+ ];
60
+
61
+ const typeIcons: Record<string, string> = {
62
+ food: "fork",
63
+ scenic: "map-marked",
64
+ hotel: "building",
65
+ activity: "flag",
66
+ };
67
+
68
+ interface ChatMessage {
69
+ id: string;
70
+ type: "text" | "pat";
71
+ senderId: string;
72
+ senderName: string;
73
+ senderColor: string;
74
+ content: string;
75
+ time: string;
76
+ /** pat 消息:拍一拍的目标名 */
77
+ patTarget?: string;
78
+ }
79
+
80
+ const initialChatMessages: ChatMessage[] = [
81
+ { id: "c1", type: "text", senderId: "1", senderName: "小明", senderColor: MEMBER_COLORS[0], content: "大家早上好!我已经到故宫午门了,人不算多~", time: "09:30" },
82
+ { id: "c2", type: "text", senderId: "2", senderName: "小红", senderColor: MEMBER_COLORS[1], content: "我快到了!地铁刚出站", time: "09:32" },
83
+ { id: "c3", type: "text", senderId: "3", senderName: "小丽", senderColor: MEMBER_COLORS[2], content: "我打车过来,大概3分钟到", time: "09:33" },
84
+ { id: "c4", type: "text", senderId: "1", senderName: "小明", senderColor: MEMBER_COLORS[0], content: "好的,大家到了在午门集合哈", time: "09:34" },
85
+ { id: "c5", type: "text", senderId: "4", senderName: "小刚", senderColor: MEMBER_COLORS[3], content: "堵车了...可能要晚一点,你们先进去吧", time: "09:36" },
86
+ { id: "c6", type: "text", senderId: "2", senderName: "小红", senderColor: MEMBER_COLORS[1], content: "到了到了!午门这边好壮观", time: "09:38" },
87
+ ];
88
+
89
+ const quickReplies = ["我快到了", "在哪集合?", "等一下我", "拍照打卡!", "走起~", "到了"];
90
+
91
+ /* ══════════════════════════════════════════
92
+ 主页面
93
+ ══════════════════════════════════════════ */
94
+
95
+ export function TeamTripPage() {
96
+ const [panelView, setPanelView] = useState("成员");
97
+ const [toastText, setToastText] = useState<string | null>(null);
98
+ const [showNotification, setShowNotification] = useState(true);
99
+ const [selectedMember, setSelectedMember] = useState<TeamMember | null>(null);
100
+ const [panelState, setPanelState] = useState(DRAWER_STATES.SMALL);
101
+ /* 聊天相关状态 */
102
+ const [chatMessages, setChatMessages] = useState<ChatMessage[]>(initialChatMessages);
103
+ const [chatInput, setChatInput] = useState("");
104
+ const [patAnimId, setPatAnimId] = useState<string | null>(null);
105
+ const chatEndRef = React.useRef<HTMLDivElement>(null);
106
+ const lastTapRef = React.useRef<{ id: string; time: number } | null>(null);
107
+
108
+ const showToast = useCallback((text: string) => {
109
+ setToastText(text);
110
+ }, []);
111
+
112
+ /* 自动滚动到聊天底部 */
113
+ useEffect(() => {
114
+ if (panelView === "聊天") {
115
+ chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
116
+ }
117
+ }, [chatMessages, panelView]);
118
+
119
+ /* 发送聊天消息 */
120
+ const sendChat = useCallback((text: string) => {
121
+ if (!text.trim()) return;
122
+ const now = new Date();
123
+ const timeStr = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
124
+ setChatMessages(prev => [...prev, {
125
+ id: `c${Date.now()}`,
126
+ type: "text",
127
+ senderId: "me",
128
+ senderName: "我",
129
+ senderColor: "#367BF6",
130
+ content: text.trim(),
131
+ time: timeStr,
132
+ }]);
133
+ setChatInput("");
134
+ }, []);
135
+
136
+ /* 拍一拍处理 — 双击头像触发 */
137
+ const handleAvatarTap = useCallback((msg: ChatMessage) => {
138
+ const now = Date.now();
139
+ const last = lastTapRef.current;
140
+ if (last && last.id === msg.senderId && now - last.time < 400) {
141
+ // 双击 → 触发拍一拍
142
+ lastTapRef.current = null;
143
+ const timeStr = `${String(new Date().getHours()).padStart(2, "0")}:${String(new Date().getMinutes()).padStart(2, "0")}`;
144
+ const patMsg: ChatMessage = {
145
+ id: `pat${Date.now()}`,
146
+ type: "pat",
147
+ senderId: "me",
148
+ senderName: "我",
149
+ senderColor: "#367BF6",
150
+ content: "",
151
+ time: timeStr,
152
+ patTarget: msg.senderName,
153
+ };
154
+ setChatMessages(prev => [...prev, patMsg]);
155
+ setPatAnimId(msg.senderId);
156
+ setTimeout(() => setPatAnimId(null), 600);
157
+ } else {
158
+ lastTapRef.current = { id: msg.senderId, time: now };
159
+ }
160
+ }, []);
161
+
162
+ useEffect(() => {
163
+ if (!toastText) return;
164
+ const timer = setTimeout(() => setToastText(null), 2200);
165
+ return () => clearTimeout(timer);
166
+ }, [toastText]);
167
+
168
+ const arrivedCount = teamMembers.filter((m) => m.status === "arrived").length;
169
+ const movingCount = teamMembers.filter((m) => m.status === "moving").length;
170
+ const offlineCount = teamMembers.filter((m) => m.status === "offline").length;
171
+ const currentStop = itineraryItems.find((i) => i.status === "current")!;
172
+
173
+ return (
174
+ <div
175
+ className="relative w-full h-[100dvh] overflow-hidden bg-background flex flex-col"
176
+ style={{ fontFamily: "var(--font-family-sans)" }}
177
+ >
178
+ {/* ── 地图背景 ── */}
179
+ <TencentMap className="absolute inset-0 w-full h-full" center={{ lat: 40.68, lng: 117.18 }} />
180
+
181
+ {/* ── 地图上的标记层 ── */}
182
+ <div className="absolute inset-0 pointer-events-none z-[5]">
183
+ {/* ── 目的地标记 ── */}
184
+ <div className="absolute pointer-events-auto" style={{ top: "52%", left: "57%", transform: "translate(-50%, -100%)" }}>
185
+ <div className="flex flex-col items-center">
186
+ <div className="bg-destructive/90 text-white text-[10px] font-medium px-[6px] py-[2px] rounded-[6px] mb-[2px] whitespace-nowrap shadow-sm">
187
+ {currentStop.name}
188
+ </div>
189
+ <IconFont name="location" size="36px" className="text-destructive" />
190
+ </div>
191
+ </div>
192
+
193
+ {/* ── 团队成员标记点 ── */}
194
+ {teamMembers.map((member) => (
195
+ <button
196
+ key={member.id}
197
+ className="absolute flex flex-col items-center pointer-events-auto"
198
+ style={{
199
+ top: `${member.lat}%`,
200
+ left: `${member.lng}%`,
201
+ transform: "translate(-50%, -50%)",
202
+ }}
203
+ onClick={() => setSelectedMember(member)}
204
+ >
205
+ <div
206
+ className={cn(
207
+ "size-[36px] rounded-full flex items-center justify-center border-[2.5px] shadow-md",
208
+ member.status === "offline" ? "opacity-50 grayscale" : ""
209
+ )}
210
+ style={{
211
+ borderColor: member.color,
212
+ backgroundColor: `${member.color}15`,
213
+ }}
214
+ >
215
+ <IconFont name="user" size="18px" style={{ color: member.color }} />
216
+ </div>
217
+ <span
218
+ className="text-[10px] font-medium mt-[2px] px-[4px] py-[1px] rounded-[4px] whitespace-nowrap"
219
+ style={{
220
+ color: member.color,
221
+ backgroundColor: `${member.color}18`,
222
+ }}
223
+ >
224
+ {member.name}
225
+ </span>
226
+ {/* 移动中的脉冲 */}
227
+ {member.status === "moving" && (
228
+ <div
229
+ className="absolute size-[36px] rounded-full animate-ping opacity-30"
230
+ style={{ backgroundColor: member.color }}
231
+ />
232
+ )}
233
+ </button>
234
+ ))}
235
+ </div>
236
+
237
+ {/* ── TopToolbar ── */}
238
+ <div className="relative z-50 pt-[8px]">
239
+ <TopToolbar
240
+ mode="center-title"
241
+ appearance="glass"
242
+ title="公司团建·古北水镇"
243
+ showBack={true}
244
+ rightActionCount={1}
245
+ onBack={() => showToast("返回上一页")}
246
+ onAction={() => showToast("更多操作")}
247
+ />
248
+ </div>
249
+
250
+ {/* ── 通知条 ── */}
251
+ <AnimatePresence>
252
+ {showNotification && (
253
+ <motion.div
254
+ className="relative z-40 px-[var(--spacing-base)] mt-[var(--spacing-xs)]"
255
+ initial={{ opacity: 0, y: -10 }}
256
+ animate={{ opacity: 1, y: 0 }}
257
+ exit={{ opacity: 0, y: -10 }}
258
+ >
259
+ <NotificationBar
260
+ variant="info"
261
+ message={`李娜 已到达${currentStop.name}`}
262
+ onClose={() => setShowNotification(false)}
263
+ actions={[{ label: "查看" }]}
264
+ />
265
+ </motion.div>
266
+ )}
267
+ </AnimatePresence>
268
+
269
+ {/* ── 底部面板 ── */}
270
+ <DraggablePanel
271
+ state={panelState}
272
+ onStateChange={setPanelState}
273
+ customSmallHeight={260}
274
+ customMediumHeight={420}
275
+ bottomBar={panelView !== "聊天" ? (
276
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-base)] pt-[var(--spacing-xs)] border-t border-black/[0.04]">
277
+ <div className="flex gap-[var(--spacing-xs)]">
278
+ <Btn
279
+ size="large"
280
+ variant="secondary"
281
+ label="分享位置"
282
+ icon={null}
283
+ onClick={() => showToast("已分享我的位置")}
284
+ className="flex-1"
285
+ />
286
+ <Btn
287
+ size="large"
288
+ variant="primary"
289
+ label={`导航至${currentStop.name}`}
290
+ icon={null}
291
+ onClick={() => showToast(`正在导航至${currentStop.name}...`)}
292
+ className="flex-[2]"
293
+ />
294
+ </div>
295
+ </div>
296
+ ) : undefined}
297
+ >
298
+ {/* 统计概览 — 紧凑内联 */}
299
+ <div className="flex items-center gap-[var(--spacing-md)] px-[var(--spacing-base)] pb-[var(--spacing-sm)]">
300
+ <span className="text-sm"><span className="font-medium text-accent">{arrivedCount}人</span> <span className="text-muted-foreground">已到达</span></span>
301
+ <span className="text-sm"><span className="font-medium text-primary">{movingCount}人</span> <span className="text-muted-foreground">在途中</span></span>
302
+ <span className="text-sm"><span className="font-medium text-foreground">{offlineCount}人</span> <span className="text-muted-foreground">离线</span></span>
303
+ </div>
304
+
305
+ {/* 分段控制器 */}
306
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-sm)]">
307
+ <SegmentedControl
308
+ value={panelView}
309
+ options={["成员", "行程", "聊天"]}
310
+ onChange={setPanelView}
311
+ size="small"
312
+ />
313
+ </div>
314
+
315
+ {/* ── 内容区 ── */}
316
+ <div className="overflow-y-auto flex-1 min-h-0">
317
+ <AnimatePresence mode="wait">
318
+ {panelView === "成员" ? (
319
+ <motion.div
320
+ key="members"
321
+ initial={{ opacity: 0, x: -16 }}
322
+ animate={{ opacity: 1, x: 0 }}
323
+ exit={{ opacity: 0, x: 16 }}
324
+ transition={{ duration: 0.15 }}
325
+ className="px-[var(--spacing-base)] pb-[var(--spacing-base)]"
326
+ >
327
+ {teamMembers.map((member, index) => (
328
+ <button
329
+ key={member.id}
330
+ onClick={() => setSelectedMember(member)}
331
+ className={cn(
332
+ "w-full flex items-center gap-[var(--spacing-sm)] py-[var(--spacing-sm)] text-left",
333
+ "active:bg-muted/30 -mx-[var(--spacing-xxs)] px-[var(--spacing-xxs)] rounded-[12px] transition-colors",
334
+ index < teamMembers.length - 1 && "border-b border-black/[0.04]"
335
+ )}
336
+ >
337
+ <div
338
+ className={cn(
339
+ "size-[40px] rounded-full flex items-center justify-center border-[2px] shrink-0",
340
+ member.status === "offline" && "opacity-50 grayscale"
341
+ )}
342
+ style={{ borderColor: member.color, backgroundColor: `${member.color}10` }}
343
+ >
344
+ <IconFont name="user" size="20px" style={{ color: member.color }} />
345
+ </div>
346
+ <div className="flex-1 min-w-0">
347
+ <div className="flex items-center gap-[6px]">
348
+ <span className="text-[14px] font-medium text-foreground leading-[20px]">
349
+ {member.name}
350
+ </span>
351
+ <Tag
352
+ label={
353
+ member.status === "arrived"
354
+ ? "已到达"
355
+ : member.status === "moving"
356
+ ? "移动中"
357
+ : "离线"
358
+ }
359
+ size="xs"
360
+ className={cn(
361
+ "border-transparent",
362
+ member.status === "arrived" && "bg-[#22C55E]/10 text-[#22C55E]",
363
+ member.status === "moving" && "bg-accent/10 text-accent",
364
+ member.status === "offline" && "bg-muted text-muted-foreground"
365
+ )}
366
+ />
367
+ </div>
368
+ <span className="text-[12px] text-muted-foreground leading-[16px]">
369
+ {member.status === "arrived"
370
+ ? `已在${currentStop.name}`
371
+ : member.status === "offline"
372
+ ? "设备离线"
373
+ : `距目的地 ${member.distance}`}
374
+ </span>
375
+ </div>
376
+ <div className="flex flex-col items-end shrink-0">
377
+ <span
378
+ className={cn(
379
+ "text-[13px] font-medium leading-[18px]",
380
+ member.status === "arrived"
381
+ ? "text-[#22C55E]"
382
+ : member.status === "offline"
383
+ ? "text-muted-foreground"
384
+ : "text-accent"
385
+ )}
386
+ >
387
+ {member.eta}
388
+ </span>
389
+ {member.status === "moving" && (
390
+ <span className="text-[11px] text-muted-foreground leading-[14px]">
391
+ 预计到达
392
+ </span>
393
+ )}
394
+ </div>
395
+ </button>
396
+ ))}
397
+ </motion.div>
398
+ ) : panelView === "行程" ? (
399
+ <motion.div
400
+ key="itinerary"
401
+ initial={{ opacity: 0, x: 16 }}
402
+ animate={{ opacity: 1, x: 0 }}
403
+ exit={{ opacity: 0, x: -16 }}
404
+ transition={{ duration: 0.15 }}
405
+ className="px-[var(--spacing-base)] pb-[var(--spacing-base)]"
406
+ >
407
+ {itineraryItems.map((item, index) => (
408
+ <div key={item.id} className="flex gap-[var(--spacing-sm)]">
409
+ <div className="flex flex-col items-center w-[24px] shrink-0">
410
+ <div
411
+ className={cn(
412
+ "size-[24px] rounded-full flex items-center justify-center shrink-0 text-[12px]",
413
+ item.status === "completed" && "bg-[#22C55E]/15 text-[#22C55E]",
414
+ item.status === "current" && "bg-accent text-accent-foreground shadow-md",
415
+ item.status === "upcoming" && "bg-card-muted text-muted-foreground"
416
+ )}
417
+ >
418
+ {item.status === "completed" ? (
419
+ <IconFont name="check-circle" size="14px" />
420
+ ) : item.status === "current" ? (
421
+ <span className="text-[11px] font-bold">{item.order}</span>
422
+ ) : (
423
+ <span className="text-[11px]">{item.order}</span>
424
+ )}
425
+ </div>
426
+ {index < itineraryItems.length - 1 && (
427
+ <div
428
+ className={cn(
429
+ "w-[1.5px] flex-1 min-h-[32px]",
430
+ item.status === "completed" ? "bg-[#22C55E]/30" : "bg-border"
431
+ )}
432
+ />
433
+ )}
434
+ </div>
435
+ <div className="flex-1 min-w-0 pb-[var(--spacing-base)]">
436
+ <div
437
+ className={cn(
438
+ "rounded-[16px] p-[var(--spacing-sm)]",
439
+ item.status === "current"
440
+ ? "bg-card shadow-[var(--elevation-sm)] border border-accent/20"
441
+ : ""
442
+ )}
443
+ >
444
+ <div className="flex items-center gap-[6px]">
445
+ <span className="text-[16px]">{typeIcons[item.type] && <IconFont name={typeIcons[item.type]} size="16px" />}</span>
446
+ <span
447
+ className={cn(
448
+ "text-[14px] font-medium leading-[20px]",
449
+ item.status === "completed"
450
+ ? "text-muted-foreground line-through"
451
+ : "text-foreground"
452
+ )}
453
+ >
454
+ {item.name}
455
+ </span>
456
+ {item.status === "current" && (
457
+ <Tag
458
+ label="当前"
459
+ size="xs"
460
+ className="bg-accent/10 text-accent border-transparent"
461
+ />
462
+ )}
463
+ </div>
464
+ <p className="text-[12px] text-muted-foreground leading-[16px] mt-[2px] truncate">
465
+ {item.address}
466
+ </p>
467
+ <div className="flex items-center gap-[4px] mt-[4px]">
468
+ <span className="text-muted-foreground">
469
+ <IconFont name="time" size="12px" />
470
+ </span>
471
+ <span
472
+ className={cn(
473
+ "text-[12px] leading-[16px]",
474
+ item.status === "current"
475
+ ? "text-accent font-medium"
476
+ : "text-muted-foreground"
477
+ )}
478
+ >
479
+ {item.time}
480
+ </span>
481
+ {item.status === "current" && (
482
+ <span className="text-[12px] text-[#22C55E] ml-[8px]">
483
+ {arrivedCount}/{teamMembers.length}人已到达
484
+ </span>
485
+ )}
486
+ </div>
487
+ </div>
488
+ </div>
489
+ </div>
490
+ ))}
491
+ </motion.div>
492
+ ) : (
493
+ /* ── 聊天视图 ── */
494
+ <motion.div
495
+ key="chat"
496
+ initial={{ opacity: 0, x: 16 }}
497
+ animate={{ opacity: 1, x: 0 }}
498
+ exit={{ opacity: 0, x: -16 }}
499
+ transition={{ duration: 0.15 }}
500
+ className="flex flex-col"
501
+ >
502
+ {/* 消息列表 */}
503
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-xs)] space-y-[2px]">
504
+ {chatMessages.map((msg, idx) => {
505
+ const isMe = msg.senderId === "me";
506
+ const showTime =
507
+ idx === 0 ||
508
+ chatMessages[idx - 1].time !== msg.time ||
509
+ chatMessages[idx - 1].type === "pat";
510
+
511
+ if (msg.type === "pat") {
512
+ return (
513
+ <motion.div
514
+ key={msg.id}
515
+ initial={{ opacity: 0, scale: 0.9 }}
516
+ animate={{ opacity: 1, scale: 1 }}
517
+ className="flex justify-center py-[6px]"
518
+ >
519
+ <span className="text-[12px] text-muted-foreground bg-black/[0.04] px-[10px] py-[3px] rounded-[10px]">
520
+ {msg.senderName} 拍了拍 {msg.patTarget}
521
+ </span>
522
+ </motion.div>
523
+ );
524
+ }
525
+
526
+ return (
527
+ <div key={msg.id}>
528
+ {showTime && (
529
+ <div className="flex justify-center py-[4px]">
530
+ <span className="text-[11px] text-muted-foreground">{msg.time}</span>
531
+ </div>
532
+ )}
533
+ <div className={cn("flex gap-[8px] py-[4px]", isMe ? "flex-row-reverse" : "flex-row")}>
534
+ <motion.button
535
+ className="shrink-0 self-start"
536
+ onDoubleClick={() => !isMe && handleAvatarTap(msg)}
537
+ onClick={() => !isMe && handleAvatarTap(msg)}
538
+ animate={
539
+ patAnimId === msg.senderId
540
+ ? { rotate: [0, -12, 12, -8, 8, 0], scale: [1, 1.15, 0.9, 1.1, 1] }
541
+ : {}
542
+ }
543
+ transition={{ duration: 0.5 }}
544
+ >
545
+ <div
546
+ className="size-[36px] rounded-full flex items-center justify-center text-[18px] border-[2px]"
547
+ style={{
548
+ borderColor: msg.senderColor,
549
+ backgroundColor: `${msg.senderColor}12`,
550
+ }}
551
+ >
552
+ <IconFont name="user" size="18px" style={{ color: msg.senderColor }} />
553
+ </div>
554
+ </motion.button>
555
+ <div className={cn("flex flex-col max-w-[70%]", isMe ? "items-end" : "items-start")}>
556
+ {!isMe && (
557
+ <span className="text-[12px] font-medium leading-[16px] mb-[2px]" style={{ color: msg.senderColor }}>
558
+ {msg.senderName}
559
+ </span>
560
+ )}
561
+ <div
562
+ className={cn(
563
+ "rounded-[16px] px-[12px] py-[8px] text-[14px] leading-[20px]",
564
+ isMe
565
+ ? "bg-accent/10 text-foreground rounded-tr-[4px]"
566
+ : "bg-card text-foreground rounded-tl-[4px] shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
567
+ )}
568
+ >
569
+ {msg.content}
570
+ </div>
571
+ </div>
572
+ </div>
573
+ </div>
574
+ );
575
+ })}
576
+ <div ref={chatEndRef} />
577
+ </div>
578
+
579
+ {/* 快捷回复 */}
580
+ <div className="px-[var(--spacing-base)] py-[var(--spacing-xs)] overflow-x-auto" style={{ scrollbarWidth: "none" }}>
581
+ <div className="flex gap-[8px]">
582
+ {quickReplies.map((text) => (
583
+ <button
584
+ key={text}
585
+ onClick={() => sendChat(text)}
586
+ className="shrink-0 px-[14px] py-[7px] rounded-[20px] bg-accent/8 text-accent text-[13px] font-medium active:bg-accent/15 transition-colors border border-accent/15"
587
+ >
588
+ {text}
589
+ </button>
590
+ ))}
591
+ </div>
592
+ </div>
593
+
594
+ {/* 输入框 */}
595
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-sm)] pt-[var(--spacing-xxs)]">
596
+ <div className="flex items-center gap-[8px]">
597
+ <div className="flex-1 flex items-center bg-card rounded-[24px] border border-black/[0.06] px-[14px] h-[40px]">
598
+ <input
599
+ type="text"
600
+ value={chatInput}
601
+ onChange={(e) => setChatInput(e.target.value)}
602
+ onKeyDown={(e) => e.key === "Enter" && sendChat(chatInput)}
603
+ placeholder="发消息...."
604
+ className="flex-1 bg-transparent text-[14px] text-foreground placeholder:text-muted-foreground outline-none"
605
+ />
606
+ </div>
607
+ <button
608
+ onClick={() => showToast("发送位置")}
609
+ className="shrink-0 size-[40px] rounded-full flex items-center justify-center text-accent active:bg-accent/10 transition-colors"
610
+ >
611
+ <IconFont name="location" size="22px" />
612
+ </button>
613
+ </div>
614
+ </div>
615
+ </motion.div>
616
+ )}
617
+ </AnimatePresence>
618
+ </div>
619
+
620
+ </DraggablePanel>
621
+
622
+ {/* ── 成员详情 BottomSheet ── */}
623
+ <BottomSheet
624
+ open={!!selectedMember}
625
+ onOpenChange={(open) => { if (!open) setSelectedMember(null); }}
626
+ title={selectedMember?.name ?? ""}
627
+ headerDescription={
628
+ selectedMember
629
+ ? selectedMember.status === "arrived"
630
+ ? `已到达 ${currentStop.name}`
631
+ : selectedMember.status === "offline"
632
+ ? "设备离线中"
633
+ : `距目的地 ${selectedMember.distance},预计 ${selectedMember.eta}到达`
634
+ : ""
635
+ }
636
+ mainActionText="呼叫TA"
637
+ secondaryActionText="发消息"
638
+ onMainAction={() => {
639
+ showToast(`正在呼叫${selectedMember?.name}...`);
640
+ setSelectedMember(null);
641
+ }}
642
+ onSecondaryAction={() => {
643
+ showToast(`已向${selectedMember?.name}发送消息`);
644
+ setSelectedMember(null);
645
+ }}
646
+ >
647
+ {selectedMember && (
648
+ <div className="flex flex-col items-center py-[var(--spacing-sm)]">
649
+ <div
650
+ className="size-[56px] rounded-full flex items-center justify-center border-[3px] mb-[var(--spacing-xs)]"
651
+ style={{
652
+ borderColor: selectedMember.color,
653
+ backgroundColor: `${selectedMember.color}10`,
654
+ }}
655
+ >
656
+ <IconFont name="user" size="28px" style={{ color: selectedMember.color }} />
657
+ </div>
658
+ <div className="flex items-center gap-[6px]">
659
+ <Tag
660
+ label={
661
+ selectedMember.status === "arrived"
662
+ ? "已到达"
663
+ : selectedMember.status === "moving"
664
+ ? "移动中"
665
+ : "离线"
666
+ }
667
+ size="sm"
668
+ className={cn(
669
+ "border-transparent",
670
+ selectedMember.status === "arrived" && "bg-[#22C55E]/10 text-[#22C55E]",
671
+ selectedMember.status === "moving" && "bg-accent/10 text-accent",
672
+ selectedMember.status === "offline" && "bg-muted text-muted-foreground"
673
+ )}
674
+ />
675
+ </div>
676
+ </div>
677
+ )}
678
+ </BottomSheet>
679
+
680
+ {/* ── Toast ── */}
681
+ <div className="fixed top-[120px] left-0 right-0 z-[200] flex justify-center pointer-events-none">
682
+ <AnimatePresence>
683
+ {toastText && (
684
+ <div className="pointer-events-auto">
685
+ <Toast
686
+ lines={[toastText]}
687
+ showIcon={true}
688
+ showClose={true}
689
+ onClose={() => setToastText(null)}
690
+ />
691
+ </div>
692
+ )}
693
+ </AnimatePresence>
694
+ </div>
695
+ </div>
696
+ );
697
+ }