@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,548 @@
1
+ import React, { useState, useCallback, useEffect } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import { cn } from "@lmy54321/design-system";
4
+ import { Tag } from "@lmy54321/design-system";
5
+ import { Btn } from "@lmy54321/design-system";
6
+ import { Toast } from "@lmy54321/design-system";
7
+ import { ImageWithFallback } from "@lmy54321/design-system";
8
+ import { IconFont } from "@lmy54321/design-system";
9
+ import { TencentMap } from "@lmy54321/design-system";
10
+ import { cdnImage } from "../config/cdn";
11
+
12
+ /* ══════════════════════════════════════════
13
+ 数据
14
+ ══════════════════════════════════════════ */
15
+
16
+ const commuteInfo = {
17
+ greeting: "早上好",
18
+ remainKm: 8.6,
19
+ estimateMin: 16,
20
+ nearestStore: 2.3,
21
+ fastestPickup: 15,
22
+ time: "08:32",
23
+ date: "3月2日 周一",
24
+ weather: "晴 12°C",
25
+ };
26
+
27
+ interface BrandStore {
28
+ id: string;
29
+ brand: string;
30
+ logo: string;
31
+ branch: string;
32
+ distance: string;
33
+ type: "destination" | "enroute" | "nearby";
34
+ suggestion: string;
35
+ color: string;
36
+ }
37
+
38
+ const brandStores: BrandStore[] = [
39
+ {
40
+ id: "1",
41
+ brand: "麦当劳",
42
+ logo: cdnImage("/images/scene/hamburger.png"),
43
+ branch: "人民广场店",
44
+ distance: "3.3km",
45
+ type: "destination",
46
+ suggestion: ""来份麦辣鸡腿堡套餐"",
47
+ color: "bg-[#DA291C]/10",
48
+ },
49
+ {
50
+ id: "2",
51
+ brand: "瑞幸咖啡",
52
+ logo: cdnImage("/images/scene/pour-coffee.png"),
53
+ branch: "人民广场店",
54
+ distance: "3.1km",
55
+ type: "destination",
56
+ suggestion: ""来杯冰拿铁换燕麦奶"",
57
+ color: "bg-[#00609C]/10",
58
+ },
59
+ {
60
+ id: "3",
61
+ brand: "达美乐披萨",
62
+ logo: cdnImage("/images/scene/pizza.png"),
63
+ branch: "科技广场店",
64
+ distance: "2.6km",
65
+ type: "enroute",
66
+ suggestion: ""点个8寸的肉酱披萨"",
67
+ color: "bg-[#006491]/10",
68
+ },
69
+ {
70
+ id: "4",
71
+ brand: "奈雪の茶",
72
+ logo: cdnImage("/images/scene/matcha.png"),
73
+ branch: "科技广场店",
74
+ distance: "1.3km",
75
+ type: "nearby",
76
+ suggestion: ""我要超浓千层抹"",
77
+ color: "bg-[#B71C2B]/10",
78
+ },
79
+ {
80
+ id: "5",
81
+ brand: "星巴克",
82
+ logo: cdnImage("/images/scene/cafe.png"),
83
+ branch: "CBD中心店",
84
+ distance: "2.9km",
85
+ type: "destination",
86
+ suggestion: ""大杯美式不加糖"",
87
+ color: "bg-[#00704A]/10",
88
+ },
89
+ {
90
+ id: "6",
91
+ brand: "肯德基",
92
+ logo: cdnImage("/images/scene/hamburger.png"),
93
+ branch: "太古里店",
94
+ distance: "1.8km",
95
+ type: "enroute",
96
+ suggestion: ""要份早餐帕尼尼"",
97
+ color: "bg-[#E4002B]/10",
98
+ },
99
+ ];
100
+
101
+ interface DealItem {
102
+ id: string;
103
+ rank: number;
104
+ name: string;
105
+ store: string;
106
+ distance: string;
107
+ image: string;
108
+ price: number;
109
+ originalPrice: number;
110
+ actionLabel: string;
111
+ isFrequent?: boolean;
112
+ }
113
+
114
+ const dealItems: DealItem[] = [
115
+ {
116
+ id: "d1",
117
+ rank: 1,
118
+ name: "大堡口福套餐",
119
+ store: "麦当劳三诺大厦店",
120
+ distance: "2.1km",
121
+ image: cdnImage("/images/scene/hamburger.png"),
122
+ price: 22.9,
123
+ originalPrice: 39.9,
124
+ actionLabel: "选 1 号",
125
+ },
126
+ {
127
+ id: "d2",
128
+ rank: 2,
129
+ name: "大橘美式咖啡",
130
+ store: "瑞幸万豪店",
131
+ distance: "1.1km",
132
+ image: cdnImage("/images/scene/pour-coffee.png"),
133
+ price: 12.9,
134
+ originalPrice: 23.9,
135
+ actionLabel: "选 2 号",
136
+ isFrequent: true,
137
+ },
138
+ {
139
+ id: "d3",
140
+ rank: 3,
141
+ name: "照烧风味牛肉披萨",
142
+ store: "达美乐江夏店",
143
+ distance: "1.2km",
144
+ image: cdnImage("/images/scene/pizza.png"),
145
+ price: 59,
146
+ originalPrice: 88.9,
147
+ actionLabel: "选 3 号",
148
+ isFrequent: true,
149
+ },
150
+ {
151
+ id: "d4",
152
+ rank: 4,
153
+ name: "超浓千层抹茶拿铁",
154
+ store: "奈雪の茶科技广场店",
155
+ distance: "1.3km",
156
+ image: cdnImage("/images/scene/matcha.png"),
157
+ price: 18.9,
158
+ originalPrice: 32,
159
+ actionLabel: "选 4 号",
160
+ },
161
+ ];
162
+
163
+ const typeLabels: Record<string, { text: string; cls: string }> = {
164
+ destination: { text: "目的地", cls: "bg-accent/10 text-accent" },
165
+ enroute: { text: "沿途", cls: "bg-chart-4/15 text-chart-4" },
166
+ nearby: { text: "附近", cls: "bg-muted text-muted-foreground" },
167
+ };
168
+
169
+ /* ══════════════════════════════════════════
170
+ 左侧面板 — 地图 + 通勤状态 + 语音点餐
171
+ ══════════════════════════════════════════ */
172
+
173
+ function LeftPanel({ onToast }: { onToast: (t: string) => void }) {
174
+ return (
175
+ <div className="flex flex-col h-full">
176
+ {/* 地图区域 */}
177
+ <div className="flex-1 relative rounded-[24px] overflow-hidden border border-card-border shadow-[var(--elevation-sm)]">
178
+ <TencentMap className="absolute inset-0 w-full h-full" center={{ lat: 31.23, lng: 121.47 }} />
179
+
180
+ {/* 左上角标注 */}
181
+ <div className="absolute top-[20px] left-[20px] z-10 flex items-center gap-[10px]">
182
+ <div className="flex items-center gap-[6px] px-[12px] py-[8px] rounded-[14px] bg-white/85 backdrop-blur-sm shadow-sm">
183
+ <div className="size-[10px] rounded-full bg-accent" />
184
+ <span className="text-[15px] text-foreground font-medium">当前位置</span>
185
+ </div>
186
+ <div className="flex items-center gap-[6px] px-[12px] py-[8px] rounded-[14px] bg-white/85 backdrop-blur-sm shadow-sm">
187
+ <div className="size-[10px] rounded-full bg-destructive" />
188
+ <span className="text-[15px] text-foreground font-medium">公司</span>
189
+ </div>
190
+ </div>
191
+
192
+ {/* 右下角 */}
193
+ <button
194
+ onClick={() => onToast("查看完整地图")}
195
+ className="absolute bottom-[20px] right-[20px] z-10 flex items-center gap-[8px] px-[16px] py-[10px] rounded-[14px] bg-white/85 backdrop-blur-sm shadow-sm active:bg-white/70 transition-colors"
196
+ >
197
+ <IconFont name="vehicle" size="16px" className="text-accent" />
198
+ <span className="text-[15px] text-foreground font-medium">查看完整路线</span>
199
+ <IconFont name="chevron-right" size="14px" />
200
+ </button>
201
+ </div>
202
+
203
+ {/* 底部通勤信息 + 语音 */}
204
+ <div className="mt-[20px] flex gap-[16px]">
205
+ {/* 通勤状态卡片 */}
206
+ <motion.div
207
+ initial={{ opacity: 0, y: 16 }}
208
+ animate={{ opacity: 1, y: 0 }}
209
+ transition={{ duration: 0.4 }}
210
+ className="flex-1"
211
+ >
212
+ <div
213
+ className={cn(
214
+ "relative p-[24px] rounded-[24px] h-full",
215
+ "bg-card border border-card-border shadow-[var(--elevation-sm)]",
216
+ "overflow-hidden"
217
+ )}
218
+ >
219
+ <div className="absolute top-0 right-0 w-[100px] h-[100px] bg-chart-4/8 rounded-full blur-[40px] -translate-y-1/3 translate-x-1/4" />
220
+ <div className="relative">
221
+ <div className="flex items-center gap-[10px] mb-[8px]">
222
+ <SunIcon size={30} />
223
+ <div>
224
+ <h2 className="text-[26px] font-semibold text-foreground leading-[34px]">
225
+ {commuteInfo.greeting}
226
+ </h2>
227
+ <p className="text-[14px] text-muted-foreground leading-[18px] mt-[2px]">
228
+ {commuteInfo.date} · {commuteInfo.weather}
229
+ </p>
230
+ </div>
231
+ </div>
232
+
233
+ <div className="flex items-center gap-[20px] mt-[16px]">
234
+ <div className="flex items-center gap-[6px]">
235
+ <NavigationIcon size={16} />
236
+ <span className="text-[16px] text-foreground leading-[22px]">
237
+ 剩余 <span className="font-bold text-[20px]">{commuteInfo.remainKm}</span> km
238
+ </span>
239
+ </div>
240
+ <div className="w-px h-[18px] bg-border/40" />
241
+ <div className="flex items-center gap-[6px]">
242
+ <IconFont name="time" size="16px" />
243
+ <span className="text-[16px] text-foreground leading-[22px]">
244
+ 约 <span className="font-bold text-[20px]">{commuteInfo.estimateMin}</span> 分钟
245
+ </span>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ </motion.div>
251
+
252
+ {/* 语音点餐按钮 */}
253
+ <motion.div
254
+ initial={{ opacity: 0, y: 16 }}
255
+ animate={{ opacity: 1, y: 0 }}
256
+ transition={{ delay: 0.06, duration: 0.4 }}
257
+ className="w-[220px]"
258
+ >
259
+ <button
260
+ onClick={() => onToast("开始语音点餐")}
261
+ className={cn(
262
+ "w-full h-full flex flex-col items-center justify-center gap-[12px] rounded-[24px]",
263
+ "bg-gradient-to-br from-accent/10 to-accent/5",
264
+ "border border-accent/15",
265
+ "active:scale-[0.97] transition-transform",
266
+ "group"
267
+ )}
268
+ >
269
+ <div
270
+ className={cn(
271
+ "size-[64px] rounded-full flex items-center justify-center",
272
+ "bg-accent text-accent-foreground",
273
+ "shadow-[0_6px_24px_rgba(54,123,246,0.35)]",
274
+ "group-active:shadow-[0_3px_12px_rgba(54,123,246,0.2)] transition-shadow"
275
+ )}
276
+ >
277
+ <MicIcon size={28} />
278
+ </div>
279
+ <div className="text-center">
280
+ <p className="text-[18px] font-semibold text-foreground leading-[24px]">语音点餐</p>
281
+ <p className="text-[13px] text-muted-foreground leading-[18px] mt-[2px]">说出你想吃的</p>
282
+ </div>
283
+ <div className="text-accent/30">
284
+ <WaveformIcon size={28} />
285
+ </div>
286
+ </button>
287
+ </motion.div>
288
+ </div>
289
+ </div>
290
+ );
291
+ }
292
+
293
+ /* ══════════════════════════════════════════
294
+ 中间面板 — 品牌门店(纵向列表)
295
+ ══════════════════════════════════════════ */
296
+
297
+ function CenterPanel({ onToast }: { onToast: (t: string) => void }) {
298
+ const destCount = brandStores.filter((s) => s.type === "destination").length;
299
+ const enrouteCount = brandStores.filter((s) => s.type === "enroute").length;
300
+
301
+ return (
302
+ <div className="flex flex-col h-full">
303
+ {/* 标题 */}
304
+ <div className="flex items-center gap-[10px] mb-[16px] shrink-0">
305
+ <IconFont name="shop" size="20px" className="text-muted-foreground" />
306
+ <span className="text-[20px] font-semibold text-foreground leading-[28px]">品牌门店</span>
307
+ <div className="flex items-center gap-[8px] ml-[8px]">
308
+ <Tag label={`目的地 ${destCount}`} size="sm" className="bg-accent/10 text-accent border-transparent" />
309
+ <Tag label={`沿途 ${enrouteCount}`} size="sm" className="bg-chart-4/15 text-chart-4 border-transparent" />
310
+ </div>
311
+ </div>
312
+
313
+ {/* 门店列表 */}
314
+ <div className="flex-1 overflow-y-auto pr-[4px]" style={{ scrollbarWidth: "thin" }}>
315
+ <div className="flex flex-col gap-[12px]">
316
+ {brandStores.map((store, index) => {
317
+ const tl = typeLabels[store.type];
318
+ return (
319
+ <motion.button
320
+ key={store.id}
321
+ initial={{ opacity: 0, x: -12 }}
322
+ animate={{ opacity: 1, x: 0 }}
323
+ transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
324
+ onClick={() => onToast(`查看${store.brand} ${store.branch}`)}
325
+ className={cn(
326
+ "w-full flex items-center gap-[16px] p-[16px] rounded-[20px] text-left",
327
+ "bg-card border border-card-border shadow-[var(--elevation-sm)]",
328
+ "active:scale-[0.98] transition-transform hover:shadow-md"
329
+ )}
330
+ >
331
+ {/* Logo */}
332
+ <div
333
+ className={cn(
334
+ "size-[56px] rounded-[14px] overflow-hidden border border-card-border shrink-0",
335
+ store.color
336
+ )}
337
+ >
338
+ <ImageWithFallback
339
+ src={store.logo}
340
+ alt={store.brand}
341
+ className="w-full h-full object-cover"
342
+ />
343
+ </div>
344
+
345
+ {/* 信息 */}
346
+ <div className="flex-1 min-w-0">
347
+ <div className="flex items-center gap-[8px]">
348
+ <span className="text-[17px] font-semibold text-foreground leading-[24px] truncate">
349
+ {store.brand}
350
+ </span>
351
+ <Tag label={tl.text} size="xs" className={cn("border-transparent shrink-0", tl.cls)} />
352
+ </div>
353
+ <div className="flex items-center gap-[6px] mt-[4px]">
354
+ <IconFont name="location" size="13px" className="text-muted-foreground" />
355
+ <span className="text-[14px] text-muted-foreground leading-[18px] truncate">
356
+ {store.branch}
357
+ </span>
358
+ <span className="text-[13px] text-muted-foreground/60 shrink-0">{store.distance}</span>
359
+ </div>
360
+ {/* 语音建议 */}
361
+ <div className="mt-[6px] px-[10px] py-[4px] rounded-[10px] bg-card-muted inline-block">
362
+ <p className="text-[13px] text-accent leading-[18px] italic">
363
+ {store.suggestion}
364
+ </p>
365
+ </div>
366
+ </div>
367
+
368
+ <IconFont name="chevron-right" size="18px" className="text-muted-foreground" />
369
+ </motion.button>
370
+ );
371
+ })}
372
+ </div>
373
+ </div>
374
+ </div>
375
+ );
376
+ }
377
+
378
+ /* ══════════════════════════════════════════
379
+ 右侧面板 — 今日特惠 + 猜你喜欢
380
+ ══════════════════════════════════════════ */
381
+
382
+ function RightPanel({ onToast }: { onToast: (t: string) => void }) {
383
+ return (
384
+ <div className="flex flex-col h-full">
385
+ {/* 标题 */}
386
+ <div className="flex items-center gap-[20px] mb-[16px] shrink-0">
387
+ <div className="flex items-center gap-[6px]">
388
+ <IconFont name="trending-up" size="18px" className="text-destructive" />
389
+ <span className="text-[20px] font-semibold text-foreground leading-[28px]">今日特惠</span>
390
+ </div>
391
+ <div className="flex items-center gap-[6px]">
392
+ <IconFont name="star" size="16px" className="text-chart-4" />
393
+ <span className="text-[16px] text-muted-foreground leading-[22px]">猜你喜欢</span>
394
+ </div>
395
+ </div>
396
+
397
+ {/* 商品列表 */}
398
+ <div className="flex-1 overflow-y-auto pr-[4px]" style={{ scrollbarWidth: "thin" }}>
399
+ <div className="flex flex-col gap-[12px]">
400
+ {dealItems.map((deal, index) => (
401
+ <motion.div
402
+ key={deal.id}
403
+ initial={{ opacity: 0, x: 12 }}
404
+ animate={{ opacity: 1, x: 0 }}
405
+ transition={{ delay: 0.15 + index * 0.06, duration: 0.3 }}
406
+ className={cn(
407
+ "w-full rounded-[20px] overflow-hidden",
408
+ "bg-card border border-card-border shadow-[var(--elevation-sm)]",
409
+ "hover:shadow-md transition-shadow"
410
+ )}
411
+ >
412
+ {/* 图片 */}
413
+ <div className="relative h-[140px] overflow-hidden">
414
+ <ImageWithFallback
415
+ src={deal.image}
416
+ alt={deal.name}
417
+ className="w-full h-full object-cover"
418
+ />
419
+ {/* 排名角标 */}
420
+ <div
421
+ className={cn(
422
+ "absolute top-0 left-0 px-[10px] py-[4px] rounded-br-[12px] text-[14px] font-bold",
423
+ deal.rank === 1
424
+ ? "bg-destructive text-destructive-foreground"
425
+ : deal.rank === 2
426
+ ? "bg-chart-4 text-white"
427
+ : "bg-primary text-primary-foreground"
428
+ )}
429
+ >
430
+ NO.{deal.rank}
431
+ </div>
432
+ {deal.isFrequent && (
433
+ <div className="absolute top-[8px] right-[8px]">
434
+ <Tag label="常点" size="xs" className="bg-white/85 text-accent border-transparent backdrop-blur-sm" />
435
+ </div>
436
+ )}
437
+ </div>
438
+
439
+ {/* 详情 */}
440
+ <div className="p-[16px]">
441
+ <h4 className="text-[17px] font-semibold text-foreground leading-[24px] truncate">
442
+ {deal.name}
443
+ </h4>
444
+ <div className="flex items-center gap-[8px] mt-[4px]">
445
+ <span className="text-[14px] text-muted-foreground leading-[18px] truncate">
446
+ {deal.store}
447
+ </span>
448
+ <span className="text-[13px] text-muted-foreground/60 shrink-0">{deal.distance}</span>
449
+ </div>
450
+
451
+ <div className="flex items-end justify-between mt-[12px]">
452
+ <div className="flex items-baseline gap-[4px]">
453
+ <span className="text-[14px] text-destructive font-medium">¥</span>
454
+ <span className="text-[26px] text-destructive font-bold leading-[30px]">{deal.price}</span>
455
+ <span className="text-[14px] text-muted-foreground/50 line-through ml-[6px]">
456
+ ¥{deal.originalPrice}
457
+ </span>
458
+ </div>
459
+ <Btn
460
+ variant="primary"
461
+ size="small"
462
+ label={deal.actionLabel}
463
+ icon={null}
464
+ onClick={() => onToast(`下单「${deal.name}」`)}
465
+ />
466
+ </div>
467
+ </div>
468
+ </motion.div>
469
+ ))}
470
+ </div>
471
+ </div>
472
+ </div>
473
+ );
474
+ }
475
+
476
+ /* ══════════════════════════════════════════
477
+ 主页面 — 车机端 1920x1080
478
+ ══════════════════════════════════════════ */
479
+
480
+ export function CommuteFoodPage() {
481
+ const [toastText, setToastText] = useState<string | null>(null);
482
+ const showToast = useCallback((text: string) => setToastText(text), []);
483
+
484
+ useEffect(() => {
485
+ if (!toastText) return;
486
+ const timer = setTimeout(() => setToastText(null), 2200);
487
+ return () => clearTimeout(timer);
488
+ }, [toastText]);
489
+
490
+ return (
491
+ <div
492
+ className="relative w-[1920px] h-[1080px] overflow-hidden flex flex-col"
493
+ style={{ fontFamily: "var(--font-family-sans)" }}
494
+ >
495
+ {/* 渐变背景 */}
496
+ <div className="absolute inset-0 bg-gradient-to-br from-[#FFF8ED] via-[#FDF6F0] to-background" />
497
+ <div className="absolute top-[-60px] right-[200px] w-[300px] h-[300px] bg-chart-4/6 rounded-full blur-[100px]" />
498
+ <div className="absolute bottom-[-60px] left-[100px] w-[250px] h-[250px] bg-accent/4 rounded-full blur-[80px]" />
499
+ <div className="absolute top-[400px] right-[-60px] w-[200px] h-[200px] bg-destructive/4 rounded-full blur-[70px]" />
500
+
501
+ {/* 顶栏 */}
502
+ <div className="relative z-10 flex items-center justify-between px-[40px] h-[72px] shrink-0">
503
+ <div className="flex items-center gap-[16px]">
504
+ <div className="flex items-center gap-[8px]">
505
+ <div className="size-[36px] rounded-full bg-accent flex items-center justify-center">
506
+ <CarIcon size={18} />
507
+ </div>
508
+ <span className="text-[22px] font-semibold text-foreground">通勤点餐</span>
509
+ </div>
510
+ </div>
511
+ <div className="flex items-center gap-[24px]">
512
+ <span className="text-[16px] text-muted-foreground">{commuteInfo.date}</span>
513
+ <span className="text-[16px] text-muted-foreground">{commuteInfo.weather}</span>
514
+ <span className="text-[28px] font-semibold text-foreground tabular-nums">{commuteInfo.time}</span>
515
+ </div>
516
+ </div>
517
+
518
+ {/* 三栏主体 */}
519
+ <div className="relative z-10 flex-1 flex gap-[24px] px-[40px] pb-[32px] min-h-0">
520
+ {/* 左侧:地图 + 通勤状态 + 语音入口 — 占 ~45% */}
521
+ <div className="w-[48%] shrink-0">
522
+ <LeftPanel onToast={showToast} />
523
+ </div>
524
+
525
+ {/* 中间:品牌门店列表 — 占 ~28% */}
526
+ <div className="flex-1 min-w-0">
527
+ <CenterPanel onToast={showToast} />
528
+ </div>
529
+
530
+ {/* 右侧:特惠推荐 — 占 ~27% */}
531
+ <div className="w-[360px] shrink-0">
532
+ <RightPanel onToast={showToast} />
533
+ </div>
534
+ </div>
535
+
536
+ {/* Toast */}
537
+ <div className="absolute top-[100px] left-0 right-0 z-[200] flex justify-center pointer-events-none">
538
+ <AnimatePresence>
539
+ {toastText && (
540
+ <div className="pointer-events-auto">
541
+ <Toast lines={[toastText]} showIcon showClose onClose={() => setToastText(null)} />
542
+ </div>
543
+ )}
544
+ </AnimatePresence>
545
+ </div>
546
+ </div>
547
+ );
548
+ }