Last active 2 weeks ago

Revision 67a8f59cf3863d074f4d26929fbca1facf1de940

ljxg_dish.html Raw
1<!DOCTYPE html>
2<html lang="zh-CN">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>庐间菜品浏览器</title>
7 <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
8 <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
9 <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
10 <style>
11 * {
12 margin: 0;
13 padding: 0;
14 box-sizing: border-box;
15 }
16
17 body {
18 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
19 background: #f5f5f5;
20 padding: 20px;
21 }
22
23 .container {
24 max-width: 1400px;
25 margin: 0 auto;
26 }
27
28 .header {
29 background: white;
30 padding: 24px;
31 border-radius: 8px;
32 margin-bottom: 20px;
33 box-shadow: 0 2px 8px rgba(0,0,0,0.1);
34 }
35
36 h1 {
37 font-size: 28px;
38 margin-bottom: 20px;
39 color: #333;
40 }
41
42 .filters {
43 display: grid;
44 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
45 gap: 16px;
46 margin-bottom: 16px;
47 }
48
49 .filter-group {
50 display: flex;
51 flex-direction: column;
52 gap: 8px;
53 }
54
55 label {
56 font-size: 14px;
57 color: #666;
58 font-weight: 500;
59 }
60
61 input, select {
62 padding: 10px 12px;
63 border: 1px solid #ddd;
64 border-radius: 4px;
65 font-size: 14px;
66 transition: border-color 0.3s;
67 }
68
69 input:focus, select:focus {
70 outline: none;
71 border-color: #1890ff;
72 }
73
74 .stats {
75 display: flex;
76 gap: 24px;
77 padding-top: 16px;
78 border-top: 1px solid #eee;
79 font-size: 14px;
80 color: #666;
81 }
82
83 .stat-item {
84 display: flex;
85 align-items: center;
86 gap: 8px;
87 }
88
89 .stat-number {
90 font-size: 20px;
91 font-weight: bold;
92 color: #1890ff;
93 }
94
95 .dishes-grid {
96 display: grid;
97 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
98 gap: 12px;
99 }
100
101 .dish-card {
102 background: white;
103 border-radius: 6px;
104 overflow: hidden;
105 box-shadow: 0 1px 4px rgba(0,0,0,0.1);
106 transition: transform 0.2s, box-shadow 0.2s;
107 }
108
109 .dish-card:hover {
110 transform: translateY(-2px);
111 box-shadow: 0 2px 8px rgba(0,0,0,0.15);
112 }
113
114 .dish-image {
115 width: 100%;
116 height: 140px;
117 object-fit: cover;
118 background: #f0f0f0;
119 }
120
121 .no-image {
122 width: 100%;
123 height: 140px;
124 display: flex;
125 align-items: center;
126 justify-content: center;
127 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
128 color: white;
129 font-size: 13px;
130 }
131
132 .dish-content {
133 padding: 10px;
134 }
135
136 .dish-header {
137 display: flex;
138 justify-content: space-between;
139 align-items: flex-start;
140 gap: 8px;
141 margin-bottom: 6px;
142 }
143
144 .dish-name {
145 font-size: 14px;
146 font-weight: bold;
147 color: #333;
148 flex: 1;
149 line-height: 1.3;
150 display: -webkit-box;
151 -webkit-line-clamp: 2;
152 -webkit-box-orient: vertical;
153 overflow: hidden;
154 text-overflow: ellipsis;
155 }
156
157 .dish-image, .no-image {
158 cursor: pointer;
159 }
160
161 .dish-image:active, .no-image:active {
162 opacity: 0.8;
163 }
164
165 .toast {
166 position: fixed;
167 top: 20px;
168 left: 50%;
169 transform: translateX(-50%);
170 background: rgba(0, 0, 0, 0.8);
171 color: white;
172 padding: 12px 24px;
173 border-radius: 4px;
174 font-size: 14px;
175 z-index: 1000;
176 animation: fadeInOut 2s ease-in-out;
177 }
178
179 @keyframes fadeInOut {
180 0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
181 10% { opacity: 1; transform: translateX(-50%) translateY(0); }
182 90% { opacity: 1; transform: translateX(-50%) translateY(0); }
183 100% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
184 }
185
186 .dish-price {
187 font-size: 16px;
188 font-weight: bold;
189 color: #ff4d4f;
190 white-space: nowrap;
191 }
192
193 .dish-info {
194 display: flex;
195 gap: 8px;
196 font-size: 11px;
197 color: #999;
198 margin-bottom: 6px;
199 }
200
201 .dish-category {
202 display: inline-block;
203 padding: 2px 6px;
204 background: #f0f0f0;
205 border-radius: 3px;
206 font-size: 10px;
207 color: #666;
208 }
209
210 .dish-methods {
211 display: flex;
212 flex-wrap: wrap;
213 gap: 4px;
214 margin-top: 6px;
215 }
216
217 .method-tag {
218 padding: 1px 6px;
219 background: #e6f7ff;
220 border: 1px solid #91d5ff;
221 border-radius: 3px;
222 font-size: 10px;
223 color: #1890ff;
224 }
225
226 .empty-state {
227 text-align: center;
228 padding: 60px 20px;
229 color: #999;
230 }
231
232 .empty-state-icon {
233 font-size: 64px;
234 margin-bottom: 16px;
235 }
236 </style>
237</head>
238<body>
239 <div id="root"></div>
240
241 <script type="text/babel">
242 const { useState, useEffect, useMemo } = React;
243
244 // 内嵌数据 - 从Gitea raw URL加载
245 const REPO_BASE = 'https://git.kitchain.cn/liusijin/ljxg_dish/raw/branch/main';
246 const DATA_URL = `${REPO_BASE}/meituan_goods_dishes_only.json`;
247
248 function App() {
249 const [dishes, setDishes] = useState([]);
250 const [searchName, setSearchName] = useState('');
251 const [hasImage, setHasImage] = useState('all');
252 const [minPrice, setMinPrice] = useState('');
253 const [maxPrice, setMaxPrice] = useState('');
254 const [selectedCategory, setSelectedCategory] = useState('all');
255 const [toast, setToast] = useState(null);
256 const [loading, setLoading] = useState(true);
257
258 useEffect(() => {
259 fetch(DATA_URL, {
260 credentials: 'include'
261 })
262 .then(res => res.json())
263 .then(data => {
264 setDishes(data);
265 setLoading(false);
266 })
267 .catch(err => {
268 console.error('加载数据失败:', err);
269 setLoading(false);
270 });
271 }, []);
272
273 const categories = useMemo(() => {
274 const cats = [...new Set(dishes.map(d => d.category))];
275 return cats.sort();
276 }, [dishes]);
277
278 const filteredDishes = useMemo(() => {
279 return dishes.filter(dish => {
280 if (searchName && !dish.name.toLowerCase().includes(searchName.toLowerCase())) {
281 return false;
282 }
283
284 if (hasImage === 'yes' && !dish.image) return false;
285 if (hasImage === 'no' && dish.image) return false;
286
287 const price = dish.specs[0]?.price || 0;
288 if (minPrice && price < parseFloat(minPrice)) return false;
289 if (maxPrice && price > parseFloat(maxPrice)) return false;
290
291 if (selectedCategory !== 'all' && dish.category !== selectedCategory) {
292 return false;
293 }
294
295 return true;
296 });
297 }, [dishes, searchName, hasImage, minPrice, maxPrice, selectedCategory]);
298
299 const stats = useMemo(() => {
300 return {
301 total: dishes.length,
302 withImage: dishes.filter(d => d.image).length,
303 filtered: filteredDishes.length
304 };
305 }, [dishes, filteredDishes]);
306
307 const showToast = (message) => {
308 setToast(message);
309 setTimeout(() => setToast(null), 2000);
310 };
311
312 if (loading) {
313 return (
314 <div className="container">
315 <div className="empty-state">
316 <div className="empty-state-icon"></div>
317 <div>加载中...</div>
318 </div>
319 </div>
320 );
321 }
322
323 return (
324 <div className="container">
325 {toast && <div className="toast">{toast}</div>}
326 <div className="header">
327 <h1>🍽 庐间菜品浏览器</h1>
328
329 <div className="filters">
330 <div className="filter-group">
331 <label>菜品名称</label>
332 <input
333 type="text"
334 placeholder="搜索菜品..."
335 value={searchName}
336 onChange={(e) => setSearchName(e.target.value)}
337 />
338 </div>
339
340 <div className="filter-group">
341 <label>分类</label>
342 <select
343 value={selectedCategory}
344 onChange={(e) => setSelectedCategory(e.target.value)}
345 >
346 <option value="all">全部分类</option>
347 {categories.map(cat => (
348 <option key={cat} value={cat}>{cat}</option>
349 ))}
350 </select>
351 </div>
352
353 <div className="filter-group">
354 <label>是否有图片</label>
355 <select
356 value={hasImage}
357 onChange={(e) => setHasImage(e.target.value)}
358 >
359 <option value="all">全部</option>
360 <option value="yes">有图片</option>
361 <option value="no">无图片</option>
362 </select>
363 </div>
364
365 <div className="filter-group">
366 <label>最低价格</label>
367 <input
368 type="number"
369 placeholder="¥0"
370 value={minPrice}
371 onChange={(e) => setMinPrice(e.target.value)}
372 />
373 </div>
374
375 <div className="filter-group">
376 <label>最高价格</label>
377 <input
378 type="number"
379 placeholder="¥999"
380 value={maxPrice}
381 onChange={(e) => setMaxPrice(e.target.value)}
382 />
383 </div>
384 </div>
385
386 <div className="stats">
387 <div className="stat-item">
388 <span>总菜品:</span>
389 <span className="stat-number">{stats.total}</span>
390 </div>
391 <div className="stat-item">
392 <span>有图片:</span>
393 <span className="stat-number">{stats.withImage}</span>
394 </div>
395 <div className="stat-item">
396 <span>当前显示:</span>
397 <span className="stat-number">{stats.filtered}</span>
398 </div>
399 </div>
400 </div>
401
402 {filteredDishes.length === 0 ? (
403 <div className="empty-state">
404 <div className="empty-state-icon">🔍</div>
405 <div>没有找到符合条件的菜品</div>
406 </div>
407 ) : (
408 <div className="dishes-grid">
409 {filteredDishes.map(dish => (
410 <DishCard key={dish.id} dish={dish} onCopy={showToast} />
411 ))}
412 </div>
413 )}
414 </div>
415 );
416 }
417
418 function DishCard({ dish, onCopy }) {
419 // 直接使用美团的图片地址
420 const imageUrl = dish.image || null;
421
422 const price = dish.specs[0]?.price || 0;
423
424 const handleImageClick = () => {
425 const imageName = dish.image ? dish.image.split('/').pop() : `${dish.id}.png`;
426 navigator.clipboard.writeText(imageName).then(() => {
427 onCopy(`已复制: ${imageName}`);
428 }).catch(() => {
429 onCopy('复制失败');
430 });
431 };
432
433 return (
434 <div className="dish-card">
435 {imageUrl ? (
436 <img
437 src={imageUrl}
438 alt={dish.name}
439 className="dish-image"
440 onClick={handleImageClick}
441 onError={(e) => {
442 e.target.style.display = 'none';
443 e.target.nextSibling.style.display = 'flex';
444 }}
445 />
446 ) : null}
447 <div
448 className="no-image"
449 style={{ display: imageUrl ? 'none' : 'flex' }}
450 onClick={handleImageClick}
451 >
452 暂无图片
453 </div>
454
455 <div className="dish-content">
456 <div className="dish-header">
457 <div className="dish-name">{dish.name}</div>
458 <div className="dish-price">¥{price}</div>
459 </div>
460
461 <div className="dish-info">
462 <span className="dish-category">{dish.category}</span>
463 <span>{dish.unit}</span>
464 </div>
465
466 {dish.methods && dish.methods.length > 0 && (
467 <div className="dish-methods">
468 {dish.methods.map((method, idx) => (
469 <span key={idx} className="method-tag">
470 {method.name}
471 </span>
472 ))}
473 </div>
474 )}
475 </div>
476 </div>
477 );
478 }
479
480 ReactDOM.render(<App />, document.getElementById('root'));
481 </script>
482</body>
483</html>
484