@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,287 @@
1
+ import React, { useState, useCallback, useEffect } from "react";
2
+ import { motion, AnimatePresence } from "motion/react";
3
+ import { cn } from "@lmy54321/design-system";
4
+ import { Btn } from "@lmy54321/design-system";
5
+ import { Tag } from "@lmy54321/design-system";
6
+ import { IconFont } from "@lmy54321/design-system";
7
+ import { Toast } from "@lmy54321/design-system";
8
+ import { DraggablePanel, DRAWER_STATES } from "@lmy54321/design-system";
9
+ import { TencentMap } from "@lmy54321/design-system";
10
+
11
+ export interface TemplateData {
12
+ icon: string;
13
+ label: string;
14
+ desc: string;
15
+ color: string;
16
+ fullDesc: string;
17
+ duration: string;
18
+ distance: string;
19
+ difficulty: string;
20
+ stops: { name: string; icon: string; dur: string; desc: string }[];
21
+ }
22
+
23
+ interface Props {
24
+ template: TemplateData;
25
+ onBack: () => void;
26
+ onCreatePlan: (plan: {
27
+ title: string;
28
+ spots: number;
29
+ image: string;
30
+ stops: { name: string; icon: string; dur: string }[];
31
+ }) => void;
32
+ }
33
+
34
+ export function TemplateDetailPage({ template, onBack, onCreatePlan }: Props) {
35
+ const [panelState, setPanelState] = useState(DRAWER_STATES.MEDIUM);
36
+ const [toast, setToast] = useState<string | null>(null);
37
+ const [creating, setCreating] = useState(false);
38
+
39
+ const showToast = useCallback((t: string) => setToast(t), []);
40
+ useEffect(() => {
41
+ if (!toast) return;
42
+ const id = setTimeout(() => setToast(null), 2200);
43
+ return () => clearTimeout(id);
44
+ }, [toast]);
45
+
46
+ const handleCreate = useCallback(() => {
47
+ setCreating(true);
48
+ setTimeout(() => {
49
+ onCreatePlan({
50
+ title: template.label,
51
+ spots: template.stops.length,
52
+ image: "/images/scene/trip-culture.png",
53
+ stops: template.stops,
54
+ });
55
+ }, 800);
56
+ }, [template, onCreatePlan]);
57
+
58
+ /* 计算总时长(小时) */
59
+ const totalHours = template.stops.reduce((sum, s) => {
60
+ const match = s.dur.match(/(\d+(\.\d+)?)/);
61
+ return sum + (match ? parseFloat(match[1]) : 0);
62
+ }, 0);
63
+
64
+ return (
65
+ <div
66
+ className="relative w-full h-[100dvh] overflow-hidden bg-background"
67
+ style={{ fontFamily: "var(--font-family-sans)" }}
68
+ >
69
+ {/* 地图背景 */}
70
+ <TencentMap className="absolute inset-0 w-full h-full" center={{ lat: 39.909, lng: 116.397 }} />
71
+
72
+ {/* 地图上的站点标记 */}
73
+ <div className="absolute inset-0 pointer-events-none z-[5]">
74
+ {template.stops.map((stop, i) => {
75
+ const angle = (i / template.stops.length) * Math.PI * 1.2 + 0.3;
76
+ const r = 15 + i * 3;
77
+ const top = 45 + Math.sin(angle) * r;
78
+ const left = 30 + (i / (template.stops.length - 1 || 1)) * 40;
79
+ return (
80
+ <div
81
+ key={stop.name}
82
+ className="absolute flex flex-col items-center pointer-events-auto"
83
+ style={{ top: `${top}%`, left: `${left}%`, transform: "translate(-50%,-100%)" }}
84
+ >
85
+ <div className="bg-black/60 text-white text-[9px] font-medium px-[5px] py-[1px] rounded-[5px] mb-[2px] whitespace-nowrap backdrop-blur-sm">
86
+ {stop.name}
87
+ </div>
88
+ <div
89
+ className={cn(
90
+ "size-[22px] rounded-full flex items-center justify-center text-white text-[10px] font-bold shadow-md",
91
+ i === 0 && "ring-2 ring-white/60"
92
+ )}
93
+ style={{ backgroundColor: template.color }}
94
+ >
95
+ {i + 1}
96
+ </div>
97
+ {i < template.stops.length - 1 && (
98
+ <div className="w-[1.5px] h-[20px]" style={{ backgroundColor: `${template.color}40` }} />
99
+ )}
100
+ </div>
101
+ );
102
+ })}
103
+ </div>
104
+
105
+ {/* DraggablePanel */}
106
+ <DraggablePanel
107
+ state={panelState}
108
+ onStateChange={setPanelState}
109
+ customSmallHeight={280}
110
+ customMediumHeight={480}
111
+ topToolbar={{
112
+ mode: "center-title",
113
+ title: template.label,
114
+ showBack: true,
115
+ onBack,
116
+ }}
117
+ showTopToolbarInStates={[DRAWER_STATES.SMALL, DRAWER_STATES.MEDIUM, DRAWER_STATES.LARGE]}
118
+ bottomBar={
119
+ <div className="px-[var(--spacing-base)] pb-[var(--spacing-base)] pt-[var(--spacing-xs)] border-t border-black/[0.04] shrink-0">
120
+ <div className="flex gap-[var(--spacing-xs)]">
121
+ <Btn
122
+ size="large"
123
+ variant="secondary"
124
+ label="收藏模板"
125
+ icon={<IconFont name="star" size="18px" />}
126
+ onClick={() => showToast("已收藏模板")}
127
+ className="flex-1"
128
+ />
129
+ <Btn
130
+ size="large"
131
+ variant="primary"
132
+ label={creating ? "生成中..." : "一键生成计划"}
133
+ icon={creating ? null : <IconFont name="add" size="18px" />}
134
+ onClick={handleCreate}
135
+ className="flex-[2]"
136
+ disabled={creating}
137
+ />
138
+ </div>
139
+ </div>
140
+ }
141
+ >
142
+ {/* 模板头部信息 */}
143
+ <div className="px-[var(--spacing-base)] shrink-0">
144
+ {/* 彩色头部横幅 */}
145
+ <div className="rounded-[16px] overflow-hidden mb-[12px]">
146
+ <div
147
+ className="relative h-[120px] flex items-end p-[14px]"
148
+ style={{ background: `linear-gradient(135deg, ${template.color}20, ${template.color}08)` }}
149
+ >
150
+ <div className="absolute top-[12px] right-[14px] opacity-10">
151
+ <IconFont name={template.icon} size="80px" style={{ color: template.color }} />
152
+ </div>
153
+ <div className="relative z-[1]">
154
+ <div className="flex items-center gap-[6px] mb-[6px]">
155
+ <div
156
+ className="size-[28px] rounded-[8px] flex items-center justify-center"
157
+ style={{ backgroundColor: `${template.color}20` }}
158
+ >
159
+ <IconFont name={template.icon} size="16px" style={{ color: template.color }} />
160
+ </div>
161
+ <Tag
162
+ label={template.difficulty}
163
+ size="sm"
164
+ className="border-transparent"
165
+ style={{ backgroundColor: `${template.color}15`, color: template.color }}
166
+ />
167
+ </div>
168
+ <p className="text-[13px] text-foreground/70 leading-[18px]">{template.fullDesc}</p>
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ {/* 路线摘要 */}
174
+ <div className="flex items-center gap-[16px] mb-[14px]">
175
+ <div className="flex items-center gap-[4px]">
176
+ <IconFont name="location" size="14px" style={{ color: template.color }} />
177
+ <span className="text-[13px] text-foreground font-medium">{template.stops.length}站</span>
178
+ </div>
179
+ <div className="flex items-center gap-[4px]">
180
+ <IconFont name="map-navigation" size="14px" className="text-muted-foreground" />
181
+ <span className="text-[13px] text-muted-foreground">{template.distance}</span>
182
+ </div>
183
+ <div className="flex items-center gap-[4px]">
184
+ <IconFont name="time" size="14px" className="text-muted-foreground" />
185
+ <span className="text-[13px] text-muted-foreground">{template.duration}</span>
186
+ </div>
187
+ <div className="flex items-center gap-[4px] ml-auto">
188
+ <IconFont name="time" size="13px" className="text-muted-foreground" />
189
+ <span className="text-[13px] text-muted-foreground">约{totalHours}h</span>
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ {/* 分割线 */}
195
+ <div className="mx-[var(--spacing-base)] h-px bg-black/[0.06] shrink-0" />
196
+
197
+ {/* 站点列表 */}
198
+ <div className="overflow-y-auto flex-1 min-h-0 px-[var(--spacing-base)] pt-[12px]">
199
+ <p className="text-[14px] font-medium text-foreground mb-[10px]">推荐路线</p>
200
+ {template.stops.map((stop, i) => (
201
+ <motion.div
202
+ key={stop.name}
203
+ className="flex gap-[var(--spacing-sm)]"
204
+ initial={{ opacity: 0, x: -10 }}
205
+ animate={{ opacity: 1, x: 0 }}
206
+ transition={{ delay: 0.06 * i, duration: 0.25 }}
207
+ >
208
+ <div className="flex flex-col items-center w-[24px] shrink-0">
209
+ <div
210
+ className="size-[24px] rounded-full flex items-center justify-center shrink-0 text-white text-[11px] font-bold"
211
+ style={{ backgroundColor: template.color }}
212
+ >
213
+ {i + 1}
214
+ </div>
215
+ {i < template.stops.length - 1 && (
216
+ <div className="w-[1.5px] flex-1 min-h-[24px]" style={{ backgroundColor: `${template.color}30` }} />
217
+ )}
218
+ </div>
219
+ <div className="flex-1 min-w-0 pb-[var(--spacing-sm)]">
220
+ <div className="flex items-center gap-[6px] rounded-[14px] p-[10px] bg-card-muted">
221
+ <div
222
+ className="size-[32px] rounded-[10px] flex items-center justify-center shrink-0"
223
+ style={{ backgroundColor: `${template.color}12` }}
224
+ >
225
+ <IconFont name={stop.icon} size="18px" style={{ color: template.color }} />
226
+ </div>
227
+ <div className="flex-1 min-w-0">
228
+ <span className="text-[14px] font-medium text-foreground leading-[20px] block truncate">{stop.name}</span>
229
+ <span className="text-[12px] text-muted-foreground leading-[16px]">{stop.desc} · {stop.dur}</span>
230
+ </div>
231
+ <IconFont name="chevron-right" size="16px" className="text-muted-foreground shrink-0" />
232
+ </div>
233
+ </div>
234
+ </motion.div>
235
+ ))}
236
+
237
+ {/* 模板说明 */}
238
+ <div className="flex items-center gap-[8px] p-[12px] rounded-[14px] bg-accent/[0.04] mt-[4px] mb-[16px]">
239
+ <IconFont name="lightbulb" size="16px" className="text-accent shrink-0" />
240
+ <p className="text-[12px] text-accent/70 leading-[17px]">
241
+ 一键生成计划后,可在行程详情中自由调整站点顺序和停留时间
242
+ </p>
243
+ </div>
244
+ </div>
245
+ </DraggablePanel>
246
+
247
+ {/* Toast */}
248
+ <div className="fixed top-[120px] left-0 right-0 z-[200] flex justify-center pointer-events-none">
249
+ <AnimatePresence>
250
+ {toast && (
251
+ <motion.div
252
+ className="pointer-events-auto"
253
+ initial={{ y: -10, opacity: 0 }}
254
+ animate={{ y: 0, opacity: 1 }}
255
+ exit={{ y: -10, opacity: 0 }}
256
+ >
257
+ <Toast lines={[toast]} showIcon showClose onClose={() => setToast(null)} />
258
+ </motion.div>
259
+ )}
260
+ </AnimatePresence>
261
+ </div>
262
+
263
+ {/* 生成中遮罩 */}
264
+ <AnimatePresence>
265
+ {creating && (
266
+ <motion.div
267
+ className="absolute inset-0 z-[300] bg-black/40 flex flex-col items-center justify-center"
268
+ initial={{ opacity: 0 }}
269
+ animate={{ opacity: 1 }}
270
+ exit={{ opacity: 0 }}
271
+ >
272
+ <div className="bg-card rounded-[20px] p-[24px] flex flex-col items-center shadow-xl">
273
+ <div
274
+ className="size-[48px] rounded-full flex items-center justify-center mb-[12px] animate-pulse"
275
+ style={{ backgroundColor: `${template.color}15` }}
276
+ >
277
+ <IconFont name={template.icon} size="24px" style={{ color: template.color }} />
278
+ </div>
279
+ <p className="text-[15px] font-medium text-foreground">正在生成计划...</p>
280
+ <p className="text-[12px] text-muted-foreground mt-[4px]">基于「{template.label}」创建行程</p>
281
+ </div>
282
+ </motion.div>
283
+ )}
284
+ </AnimatePresence>
285
+ </div>
286
+ );
287
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": false,
16
+ "noUnusedParameters": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "paths": {
19
+ "@/*": ["./src/*"]
20
+ }
21
+ },
22
+ "include": ["src"]
23
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+ import path from "path";
5
+
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ resolve: {
9
+ alias: {
10
+ "@": path.resolve(__dirname, "./src"),
11
+ },
12
+ },
13
+ server: {
14
+ host: "0.0.0.0",
15
+ port: 5173,
16
+ allowedHosts: "all",
17
+ },
18
+ });