@lmy54321/design-system 1.1.4 → 1.3.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,345 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import { cn } from "@lmy54321/design-system";
4
+ import { SegmentedControl } from "@lmy54321/design-system";
5
+ import { Btn } from "@lmy54321/design-system";
6
+ import { Tag } from "@lmy54321/design-system";
7
+ import { StatGrid } from "@lmy54321/design-system";
8
+ import { NotificationBar } from "@lmy54321/design-system";
9
+ import { Toast } from "@lmy54321/design-system";
10
+ import { BottomSheet } from "@lmy54321/design-system";
11
+ import { IconFont } from "@lmy54321/design-system";
12
+ import { TencentMap } from "@lmy54321/design-system";
13
+ import { DraggablePanel, DRAWER_STATES } from "@lmy54321/design-system";
14
+
15
+ const COLORS = ["#367BF6", "#FF6B35", "#22C55E", "#A855F7", "#F59E0B"];
16
+
17
+ interface Member { id: string; name: string; color: string; status: "moving"|"arrived"|"offline"; distance: string; eta: string; lat: number; lng: number; role: string; }
18
+ interface Stop { id: string; order: number; name: string; address: string; time: string; dur: string; status: "completed"|"current"|"upcoming"; icon: string; }
19
+
20
+ const members: Member[] = [
21
+ { id:"1", name:"小明", color:COLORS[0], status:"arrived", distance:"0m", eta:"已到达", lat:48, lng:55, role:"组织者" },
22
+ { id:"2", name:"小红", color:COLORS[1], status:"moving", distance:"1.2km", eta:"5分钟", lat:35, lng:40, role:"成员" },
23
+ { id:"3", name:"小刚", color:COLORS[2], status:"moving", distance:"3.5km", eta:"12分钟", lat:62, lng:30, role:"成员" },
24
+ { id:"4", name:"小丽", color:COLORS[3], status:"moving", distance:"0.8km", eta:"3分钟", lat:42, lng:72, role:"成员" },
25
+ { id:"5", name:"小强", color:COLORS[4], status:"offline", distance:"--", eta:"离线", lat:70, lng:22, role:"成员" },
26
+ ];
27
+
28
+ const stops: Stop[] = [
29
+ { id:"1", order:1, name:"天安门广场", address:"北京市东城区长安街", time:"08:30", dur:"1h", status:"completed", icon:"map-marked" },
30
+ { id:"2", order:2, name:"故宫博物院", address:"北京市东城区景山前街4号", time:"09:45", dur:"3h", status:"current", icon:"museum" },
31
+ { id:"3", order:3, name:"故宫角楼咖啡", address:"故宫神武门外东侧", time:"13:00", dur:"40min", status:"upcoming", icon:"tea" },
32
+ { id:"4", order:4, name:"景山公园", address:"北京市西城区景山西街44号", time:"14:00", dur:"1.5h", status:"upcoming", icon:"map-marked" },
33
+ { id:"5", order:5, name:"南锣鼓巷", address:"北京市东城区南锣鼓巷", time:"16:00", dur:"2h", status:"upcoming", icon:"shop" },
34
+ { id:"6", order:6, name:"簋街·胡大饭馆", address:"北京市东城区东直门内大街", time:"18:30", dur:"1.5h", status:"upcoming", icon:"fork" },
35
+ ];
36
+
37
+ interface Props { onBack: () => void; }
38
+
39
+ interface ChatMsg { id: string; memberId: string; text: string; time: string; type: "text"|"location"|"quick"; }
40
+
41
+ const initMessages: ChatMsg[] = [
42
+ { id:"c1", memberId:"1", text:"大家早上好!我已经到故宫午门了,人不算多~", time:"09:30", type:"text" },
43
+ { id:"c2", memberId:"2", text:"我快到了!地铁刚出站", time:"09:32", type:"text" },
44
+ { id:"c3", memberId:"4", text:"我打车过来,大概3分钟到", time:"09:33", type:"text" },
45
+ { id:"c4", memberId:"1", text:"好的,大家到了在午门集合哈", time:"09:34", type:"text" },
46
+ { id:"c5", memberId:"3", text:"堵车了...可能要晚一点,你们先进去吧", time:"09:40", type:"text" },
47
+ { id:"c6", memberId:"1", text:"[位置] 故宫博物院·午门入口", time:"09:41", type:"location" },
48
+ { id:"c7", memberId:"2", text:"到了到了!看到你了小明", time:"09:45", type:"text" },
49
+ { id:"c8", memberId:"4", text:"我也到了,在买票", time:"09:46", type:"text" },
50
+ { id:"c9", memberId:"1", text:"记得带身份证刷码进,不用买票", time:"09:46", type:"text" },
51
+ { id:"c10", memberId:"5", text:"抱歉大家,我今天身体不太舒服,可能去不了了", time:"09:50", type:"text" },
52
+ { id:"c11", memberId:"1", text:"没事小强,你好好休息!我们拍照给你看", time:"09:51", type:"text" },
53
+ ];
54
+
55
+ const quickMsgs = ["我快到了", "在哪集合?", "等一下我", "拍照打卡!", "走起~", "到了"];
56
+
57
+ export function PlanDetailPage({ onBack }: Props) {
58
+ const [view, setView] = useState("成员");
59
+ const [toast, setToast] = useState<string|null>(null);
60
+ const [notif, setNotif] = useState(true);
61
+ const [selMember, setSelMember] = useState<Member|null>(null);
62
+ const [panelState, setPanelState] = useState(DRAWER_STATES.SMALL);
63
+ const [messages, setMessages] = useState<ChatMsg[]>(initMessages);
64
+ const [inputText, setInputText] = useState("");
65
+ const chatEndRef = useRef<HTMLDivElement>(null);
66
+ const chatScrollRef = useRef<HTMLDivElement>(null);
67
+
68
+ const showToast = useCallback((t: string) => setToast(t), []);
69
+ useEffect(() => { if (!toast) return; const id = setTimeout(() => setToast(null), 2200); return () => clearTimeout(id); }, [toast]);
70
+
71
+ const scrollToBottom = useCallback(() => {
72
+ setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior:"smooth" }), 50);
73
+ }, []);
74
+
75
+ useEffect(() => { if (view === "聊天") { setPanelState(DRAWER_STATES.LARGE); scrollToBottom(); } }, [view, scrollToBottom]);
76
+
77
+ const sendMsg = useCallback((text: string) => {
78
+ if (!text.trim()) return;
79
+ const now = new Date();
80
+ const timeStr = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`;
81
+ const newMsg: ChatMsg = { id:`u${Date.now()}`, memberId:"self", text, time:timeStr, type:"text" };
82
+ setMessages(prev => [...prev, newMsg]);
83
+ setInputText("");
84
+ scrollToBottom();
85
+ // 模拟自动回复
86
+ const replyMember = members.filter(m => m.status !== "offline")[Math.floor(Math.random()*3)];
87
+ const replies = ["好的!","收到~","哈哈没问题","OK 马上来","太棒了!","等我一下~","在路上了"];
88
+ setTimeout(() => {
89
+ setMessages(prev => [...prev, {
90
+ id:`r${Date.now()}`, memberId:replyMember.id, text:replies[Math.floor(Math.random()*replies.length)],
91
+ time:timeStr, type:"text"
92
+ }]);
93
+ scrollToBottom();
94
+ }, 800 + Math.random()*1200);
95
+ }, [scrollToBottom]);
96
+
97
+ const arrived = members.filter(m => m.status === "arrived").length;
98
+ const moving = members.filter(m => m.status === "moving").length;
99
+ const offline = members.filter(m => m.status === "offline").length;
100
+ const cur = stops.find(s => s.status === "current")!;
101
+ const done = stops.filter(s => s.status === "completed").length;
102
+
103
+ const upcomingPos = [{ t:"45%",l:"52%" },{ t:"28%",l:"60%" },{ t:"22%",l:"40%" },{ t:"15%",l:"72%" }];
104
+
105
+ return (
106
+ <div className="relative w-full h-[100dvh] overflow-hidden bg-background flex flex-col" style={{ fontFamily:"var(--font-family-sans)" }}>
107
+ {/* 地图背景 */}
108
+ <TencentMap className="absolute inset-0 w-full h-full" center={{ lat: 39.909, lng: 116.397 }} />
109
+
110
+ {/* 地图标记层 */}
111
+ <div className="absolute inset-0 pointer-events-none z-[5]">
112
+ {/* 已完成站点 */}
113
+ <div className="absolute flex flex-col items-center opacity-50 pointer-events-auto" style={{ top:"72%", left:"18%", transform:"translate(-50%,-100%)" }}>
114
+ <div className="bg-[#22C55E]/70 text-white text-[9px] font-medium px-[5px] py-[1px] rounded-[5px] mb-[2px]">天安门广场</div>
115
+ <div className="size-[20px] rounded-full bg-[#22C55E]/40 flex items-center justify-center text-[#22C55E]"><IconFont name="check-circle" size="12px"/></div>
116
+ </div>
117
+
118
+ {/* 即将站点 */}
119
+ {stops.filter(s => s.status === "upcoming").map((s, i) => {
120
+ const p = upcomingPos[i]; if (!p) return null;
121
+ return (<div key={s.id} className="absolute flex flex-col items-center opacity-40 pointer-events-auto" style={{ top:p.t, left:p.l, transform:"translate(-50%,-100%)" }}>
122
+ <div className="bg-black/20 text-white text-[9px] font-medium px-[5px] py-[1px] rounded-[5px] mb-[2px] whitespace-nowrap">{s.name}</div>
123
+ <div className="size-[18px] rounded-full border border-dashed border-[#8B8B8B] bg-white/50 flex items-center justify-center text-[#8B8B8B] text-[8px] font-semibold">{s.order}</div>
124
+ </div>);
125
+ })}
126
+
127
+ {/* 当前目的地 */}
128
+ <div className="absolute pointer-events-auto" style={{ top:"50%", left:"50%", transform:"translate(-50%,-100%)" }}>
129
+ <div className="flex flex-col items-center">
130
+ <div className="bg-destructive/90 text-white text-[10px] font-medium px-[6px] py-[2px] rounded-[6px] mb-[2px] whitespace-nowrap shadow-sm">{cur.name}</div>
131
+ <IconFont name="location" size="36px" className="text-destructive"/>
132
+ <div className="absolute bottom-[6px] size-[16px] rounded-full bg-destructive/20 animate-ping"/>
133
+ </div>
134
+ </div>
135
+
136
+ {/* 成员位置 */}
137
+ {members.map(m => (
138
+ <button key={m.id} className="absolute flex flex-col items-center pointer-events-auto" style={{ top:`${m.lat}%`, left:`${m.lng}%`, transform:"translate(-50%,-50%)" }} onClick={() => setSelMember(m)}>
139
+ <div className={cn("size-[36px] rounded-full flex items-center justify-center border-[2.5px] shadow-md", m.status === "offline" && "opacity-50 grayscale")} style={{ borderColor:m.color, backgroundColor:`${m.color}20` }}>
140
+ <IconFont name="user" size="18px" style={{ color:m.color }}/>
141
+ </div>
142
+ <span className="text-[10px] font-medium mt-[2px] px-[4px] py-[1px] rounded-[4px] whitespace-nowrap" style={{ color:m.color, backgroundColor:`${m.color}18` }}>{m.name}</span>
143
+ {m.status === "moving" && <div className="absolute size-[36px] rounded-full animate-ping opacity-25" style={{ backgroundColor:m.color }}/>}
144
+ </button>
145
+ ))}
146
+ </div>
147
+
148
+ {/* 进度条 — 固定悬浮在 TopToolbar 下方 */}
149
+ <div className="absolute z-40 left-0 right-0 top-[88px] px-[var(--spacing-base)]">
150
+ <div className="flex items-center gap-[8px] bg-white/70 backdrop-blur-[16px] rounded-[14px] px-[12px] py-[8px] shadow-sm border border-black/[0.06]">
151
+ <div className="flex items-center gap-[4px] text-[12px] text-muted-foreground">
152
+ <IconFont name="location" size="14px" className="text-accent"/>
153
+ <span className="text-foreground font-medium">{done}/{stops.length}</span><span>站</span>
154
+ </div>
155
+ <div className="flex-1 h-[4px] bg-black/[0.06] rounded-full overflow-hidden">
156
+ <motion.div className="h-full bg-accent rounded-full" initial={{ width:0 }} animate={{ width:`${((done+0.5)/stops.length)*100}%` }} transition={{ duration:0.8 }}/>
157
+ </div>
158
+ <div className="flex items-center gap-[4px] text-[12px]">
159
+ <IconFont name="time" size="14px" className="text-muted-foreground"/><span className="text-foreground font-medium">09:45</span>
160
+ </div>
161
+ </div>
162
+ </div>
163
+
164
+ {/* 通知 — 固定悬浮在进度条下方 */}
165
+ <AnimatePresence>{notif && (
166
+ <motion.div className="absolute z-40 left-0 right-0 top-[140px] px-[var(--spacing-base)]" initial={{ opacity:0,y:-10 }} animate={{ opacity:1,y:0 }} exit={{ opacity:0,y:-10 }}>
167
+ <NotificationBar variant="info" message={`小明 已到达${cur.name}`} onClose={() => setNotif(false)} actions={[{ label:"查看" }]}/>
168
+ </motion.div>
169
+ )}</AnimatePresence>
170
+
171
+ {/* 底部面板 — TopToolbar 通过 DraggablePanel 的 topToolbar prop 管理 */}
172
+ <DraggablePanel
173
+ state={panelState}
174
+ onStateChange={setPanelState}
175
+ customSmallHeight={260}
176
+ customMediumHeight={400}
177
+ topToolbar={{
178
+ mode: "center-title",
179
+ title: "故宫+景山一日游",
180
+ showBack: true,
181
+ rightActionCount: 1,
182
+ onBack: onBack,
183
+ onAction: () => showToast("更多操作"),
184
+ }}
185
+ showTopToolbarInStates={[DRAWER_STATES.SMALL, DRAWER_STATES.MEDIUM, DRAWER_STATES.LARGE]}
186
+ bottomBar={view !== "聊天" ? (
187
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-base)] pt-[var(--spacing-xs)] border-t border-black/[0.04] shrink-0">
188
+ <div className="flex gap-[var(--spacing-xs)]">
189
+ <Btn size="large" variant="secondary" label="分享位置" icon={null} onClick={() => showToast("已分享我的位置")} className="flex-1"/>
190
+ <Btn size="large" variant="primary" label={`导航至${cur.name}`} icon={null} onClick={() => showToast(`正在导航至${cur.name}...`)} className="flex-[2]"/>
191
+ </div>
192
+ </div>
193
+ ) : undefined}
194
+ >
195
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-xs)] shrink-0">
196
+ <SegmentedControl value={view} options={["成员","行程","聊天"]} onChange={setView} size="small" className="w-full [&>button]:flex-1"/>
197
+ </div>
198
+ <div className="flex items-center gap-[var(--spacing-sm)] px-[var(--spacing-base)] pb-[var(--spacing-sm)] shrink-0">
199
+ <span className="text-xs"><span className="font-medium text-accent">{arrived}</span> <span className="text-muted-foreground">已到达</span></span>
200
+ <span className="text-muted-foreground/30">·</span>
201
+ <span className="text-xs"><span className="font-medium text-primary">{moving}</span> <span className="text-muted-foreground">在途中</span></span>
202
+ <span className="text-muted-foreground/30">·</span>
203
+ <span className="text-xs"><span className="font-medium text-foreground">{offline}</span> <span className="text-muted-foreground">离线</span></span>
204
+ </div>
205
+
206
+ <div className="overflow-y-auto flex-1 min-h-0" ref={view==="聊天"?chatScrollRef:undefined}>
207
+ <AnimatePresence mode="wait">
208
+ {view === "成员" ? (
209
+ <motion.div key="m" initial={{ opacity:0,x:-16 }} animate={{ opacity:1,x:0 }} exit={{ opacity:0,x:16 }} transition={{ duration:0.15 }} className="px-[var(--spacing-base)] pb-[var(--spacing-base)]">
210
+ {members.map((m, i) => (
211
+ <button key={m.id} onClick={() => setSelMember(m)} className={cn("w-full flex items-center gap-[var(--spacing-sm)] py-[var(--spacing-sm)] text-left","active:bg-muted/30 -mx-[var(--spacing-xxs)] px-[var(--spacing-xxs)] rounded-[12px] transition-colors", i < members.length-1 && "border-b border-black/[0.04]")}>
212
+ <div className={cn("size-[40px] rounded-full flex items-center justify-center border-[2px] shrink-0", m.status==="offline" && "opacity-50 grayscale")} style={{ borderColor:m.color, backgroundColor:`${m.color}10` }}>
213
+ <IconFont name="user" size="20px" style={{ color:m.color }}/>
214
+ </div>
215
+ <div className="flex-1 min-w-0">
216
+ <div className="flex items-center gap-[6px]">
217
+ <span className="text-[14px] font-medium text-foreground leading-[20px]">{m.name}</span>
218
+ {m.role === "组织者" && <Tag label="组织者" size="xs" className="bg-[#F59E0B]/10 text-[#F59E0B] border-transparent"/>}
219
+ <Tag label={m.status==="arrived"?"已到达":m.status==="moving"?"移动中":"离线"} size="xs" className={cn("border-transparent", m.status==="arrived" && "bg-[#22C55E]/10 text-[#22C55E]", m.status==="moving" && "bg-accent/10 text-accent", m.status==="offline" && "bg-muted text-muted-foreground")}/>
220
+ </div>
221
+ <span className="text-[12px] text-muted-foreground leading-[16px]">{m.status==="arrived"?`已在${cur.name}`:m.status==="offline"?"设备离线":`距目的地 ${m.distance}`}</span>
222
+ </div>
223
+ <div className="flex flex-col items-end shrink-0">
224
+ <span className={cn("text-[13px] font-medium leading-[18px]", m.status==="arrived"?"text-[#22C55E]":m.status==="offline"?"text-muted-foreground":"text-accent")}>{m.eta}</span>
225
+ {m.status === "moving" && <span className="text-[11px] text-muted-foreground leading-[14px]">预计到达</span>}
226
+ </div>
227
+ </button>
228
+ ))}
229
+ </motion.div>
230
+ ) : view === "行程" ? (
231
+ <motion.div key="it" initial={{ opacity:0,x:16 }} animate={{ opacity:1,x:0 }} exit={{ opacity:0,x:-16 }} transition={{ duration:0.15 }} className="px-[var(--spacing-base)] pb-[var(--spacing-base)]">
232
+ {stops.map((s, i) => (
233
+ <div key={s.id} className="flex gap-[var(--spacing-sm)]">
234
+ <div className="flex flex-col items-center w-[24px] shrink-0">
235
+ <div className={cn("size-[24px] rounded-full flex items-center justify-center shrink-0", s.status==="completed" && "bg-[#22C55E]/15 text-[#22C55E]", s.status==="current" && "bg-accent text-white shadow-md", s.status==="upcoming" && "bg-card-muted text-muted-foreground")}>
236
+ {s.status==="completed" ? <IconFont name="check-circle" size="14px"/> : <span className="text-[11px] font-bold">{s.order}</span>}
237
+ </div>
238
+ {i < stops.length-1 && <div className={cn("w-[1.5px] flex-1 min-h-[32px]", s.status==="completed"?"bg-[#22C55E]/30":"bg-border")}/>}
239
+ </div>
240
+ <div className="flex-1 min-w-0 pb-[var(--spacing-base)]">
241
+ <div className={cn("rounded-[16px] p-[var(--spacing-sm)]", s.status==="current" && "bg-card shadow-[var(--elevation-sm)] border border-accent/20")}>
242
+ <div className="flex items-center gap-[6px]">
243
+ <IconFont name={s.icon} size="16px" className={s.status==="completed"?"text-muted-foreground":"text-foreground"}/>
244
+ <span className={cn("text-[14px] font-medium leading-[20px]", s.status==="completed"?"text-muted-foreground line-through":"text-foreground")}>{s.name}</span>
245
+ {s.status==="current" && <Tag label="当前" size="xs" className="bg-accent/10 text-accent border-transparent"/>}
246
+ </div>
247
+ <p className="text-[12px] text-muted-foreground leading-[16px] mt-[2px] truncate">{s.address}</p>
248
+ <div className="flex items-center gap-[4px] mt-[4px]">
249
+ <IconFont name="time" size="12px" className="text-muted-foreground"/>
250
+ <span className={cn("text-[12px] leading-[16px]", s.status==="current"?"text-accent font-medium":"text-muted-foreground")}>{s.time}</span>
251
+ <span className="text-[12px] text-muted-foreground ml-[4px]">· {s.dur}</span>
252
+ {s.status==="current" && <span className="text-[12px] text-[#22C55E] ml-[8px]">{arrived}/{members.length}人已到达</span>}
253
+ </div>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ ))}
258
+ </motion.div>
259
+ ) : (
260
+ <motion.div key="chat" initial={{ opacity:0,x:16 }} animate={{ opacity:1,x:0 }} exit={{ opacity:0,x:-16 }} transition={{ duration:0.15 }} className="px-[var(--spacing-sm)] pb-[16px]">
261
+ {messages.map((msg) => {
262
+ const isSelf = msg.memberId === "self";
263
+ const sender = members.find(m => m.id === msg.memberId);
264
+ return (
265
+ <motion.div key={msg.id} className={cn("flex gap-[8px] mb-[10px]", isSelf && "flex-row-reverse")} initial={{ opacity:0, y:8 }} animate={{ opacity:1, y:0 }} transition={{ duration:0.2 }}>
266
+ {!isSelf && sender && (
267
+ <div className="size-[28px] rounded-full flex items-center justify-center border-[1.5px] shrink-0 mt-[2px]" style={{ borderColor:sender.color, backgroundColor:`${sender.color}10` }}>
268
+ <IconFont name="user" size="14px" style={{ color:sender.color }}/>
269
+ </div>
270
+ )}
271
+ <div className={cn("max-w-[75%] min-w-0", isSelf && "items-end")}>
272
+ {!isSelf && sender && <span className="text-[11px] font-medium leading-[14px] mb-[2px] block" style={{ color:sender.color }}>{sender.name}</span>}
273
+ <div className={cn("rounded-[16px] px-[12px] py-[8px] text-[13px] leading-[19px] break-words",
274
+ isSelf ? "bg-accent text-white rounded-tr-[4px]" :
275
+ msg.type === "location" ? "bg-[#22C55E]/10 text-foreground border border-[#22C55E]/20 rounded-tl-[4px]" :
276
+ "bg-card-muted text-foreground rounded-tl-[4px]"
277
+ )}>
278
+ {msg.type === "location" && <span className="inline-flex items-center gap-[3px] mr-[2px]"><IconFont name="location" size="13px" className="text-[#22C55E]"/></span>}
279
+ {msg.text}
280
+ </div>
281
+ <span className={cn("text-[10px] text-muted-foreground leading-[14px] mt-[2px] block", isSelf && "text-right")}>{msg.time}</span>
282
+ </div>
283
+ {isSelf && <div className="size-[28px] rounded-full bg-accent/15 flex items-center justify-center shrink-0 mt-[2px]"><IconFont name="user" size="14px" className="text-accent"/></div>}
284
+ </motion.div>
285
+ );
286
+ })}
287
+ <div ref={chatEndRef}/>
288
+ </motion.div>
289
+ )}
290
+ </AnimatePresence>
291
+ </div>
292
+
293
+ {/* 底部操作 — 聊天视图时显示输入框 */}
294
+ {view === "聊天" && (
295
+ <div className="px-[var(--spacing-sm)] pb-[var(--spacing-sm)] pt-[var(--spacing-xs)] border-t border-black/[0.04] shrink-0">
296
+ <div className="flex gap-[6px] overflow-x-auto pb-[8px] scrollbar-hide">
297
+ {quickMsgs.map(q => (
298
+ <button key={q} onClick={() => sendMsg(q)} className="shrink-0 px-[10px] py-[5px] rounded-[20px] bg-accent/8 text-accent text-[12px] font-medium active:bg-accent/15 transition-colors border border-accent/15">{q}</button>
299
+ ))}
300
+ </div>
301
+ <div className="flex gap-[8px] items-end">
302
+ <div className="flex-1 relative">
303
+ <input
304
+ type="text"
305
+ value={inputText}
306
+ onChange={e => setInputText(e.target.value)}
307
+ onKeyDown={e => { if (e.key === "Enter" && !e.nativeEvent.isComposing) sendMsg(inputText); }}
308
+ placeholder="发消息..."
309
+ className="w-full h-[38px] px-[14px] rounded-[20px] bg-card-muted text-[14px] text-foreground placeholder:text-muted-foreground outline-none border border-black/[0.06] focus:border-accent/40 transition-colors"
310
+ />
311
+ </div>
312
+ <button onClick={() => { if (inputText.trim()) sendMsg(inputText); else showToast("[位置] 已分享当前位置"); }} className={cn("size-[38px] rounded-full flex items-center justify-center shrink-0 transition-colors", inputText.trim() ? "bg-accent text-white" : "bg-card-muted text-muted-foreground")}>
313
+ <IconFont name={inputText.trim() ? "arrow-up" : "location"} size="18px"/>
314
+ </button>
315
+ </div>
316
+ </div>
317
+ )}
318
+ </DraggablePanel>
319
+
320
+ {/* 成员详情 BottomSheet */}
321
+ <BottomSheet open={!!selMember} onOpenChange={o => { if(!o) setSelMember(null); }} title={selMember?.name ?? ""} headerDescription={selMember ? (selMember.status==="arrived"?`已到达 ${cur.name}`:selMember.status==="offline"?"设备离线中":`距目的地 ${selMember.distance},预计 ${selMember.eta}到达`) : ""} mainActionText="呼叫TA" secondaryActionText="发消息" onMainAction={() => { showToast(`正在呼叫${selMember?.name}...`); setSelMember(null); }} onSecondaryAction={() => { showToast(`已向${selMember?.name}发送消息`); setSelMember(null); }}>
322
+ {selMember && (
323
+ <div className="flex flex-col items-center py-[var(--spacing-sm)]">
324
+ <div className="size-[56px] rounded-full flex items-center justify-center border-[3px] mb-[var(--spacing-xs)]" style={{ borderColor:selMember.color, backgroundColor:`${selMember.color}10` }}>
325
+ <IconFont name="user" size="28px" style={{ color:selMember.color }}/>
326
+ </div>
327
+ <div className="flex items-center gap-[6px]">
328
+ {selMember.role === "组织者" && <Tag label="组织者" size="sm" className="bg-[#F59E0B]/10 text-[#F59E0B] border-transparent"/>}
329
+ <Tag label={selMember.status==="arrived"?"已到达":selMember.status==="moving"?"移动中":"离线"} size="sm" className={cn("border-transparent", selMember.status==="arrived" && "bg-[#22C55E]/10 text-[#22C55E]", selMember.status==="moving" && "bg-accent/10 text-accent", selMember.status==="offline" && "bg-muted text-muted-foreground")}/>
330
+ </div>
331
+ </div>
332
+ )}
333
+ </BottomSheet>
334
+
335
+ {/* Toast */}
336
+ <div className="fixed top-[120px] left-0 right-0 z-[200] flex justify-center pointer-events-none">
337
+ <AnimatePresence>{toast && (
338
+ <motion.div className="pointer-events-auto" initial={{ y:-10,opacity:0 }} animate={{ y:0,opacity:1 }} exit={{ y:-10,opacity:0 }}>
339
+ <Toast lines={[toast]} showIcon showClose onClose={() => setToast(null)}/>
340
+ </motion.div>
341
+ )}</AnimatePresence>
342
+ </div>
343
+ </div>
344
+ );
345
+ }