Last active 2 weeks ago

Revision b45b66fce5a8c42b168a06d43e078c5a6867399a

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 .then(res => res.json())
261 .then(data => {
262 setDishes(data);
263 setLoading(false);
264 })
265 .catch(err => {
266 console.error('加载数据失败:', err);
267 setLoading(false);
268 });
269 }, []);
270
271 const categories = useMemo(() => {
272 const cats = [...new Set(dishes.map(d => d.category))];
273 return cats.sort();
274 }, [dishes]);
275
276 const filteredDishes = useMemo(() => {
277 return dishes.filter(dish => {
278 if (searchName && !dish.name.toLowerCase().includes(searchName.toLowerCase())) {
279 return false;
280 }
281
282 if (hasImage === 'yes' && !dish.image) return false;
283 if (hasImage === 'no' && dish.image) return false;
284
285 const price = dish.specs[0]?.price || 0;
286 if (minPrice && price < parseFloat(minPrice)) return false;
287 if (maxPrice && price > parseFloat(maxPrice)) return false;
288
289 if (selectedCategory !== 'all' && dish.category !== selectedCategory) {
290 return false;
291 }
292
293 return true;
294 });
295 }, [dishes, searchName, hasImage, minPrice, maxPrice, selectedCategory]);
296
297 const stats = useMemo(() => {
298 return {
299 total: dishes.length,
300 withImage: dishes.filter(d => d.image).length,
301 filtered: filteredDishes.length
302 };
303 }, [dishes, filteredDishes]);
304
305 const showToast = (message) => {
306 setToast(message);
307 setTimeout(() => setToast(null), 2000);
308 };
309
310 if (loading) {
311 return (
312 <div className="container">
313 <div className="empty-state">
314 <div className="empty-state-icon"></div>
315 <div>加载中...</div>
316 </div>
317 </div>
318 );
319 }
320
321 return (
322 <div className="container">
323 {toast && <div className="toast">{toast}</div>}
324 <div className="header">
325 <h1>🍽 庐间菜品浏览器</h1>
326
327 <div className="filters">
328 <div className="filter-group">
329 <label>菜品名称</label>
330 <input
331 type="text"
332 placeholder="搜索菜品..."
333 value={searchName}
334 onChange={(e) => setSearchName(e.target.value)}
335 />
336 </div>
337
338 <div className="filter-group">
339 <label>分类</label>
340 <select
341 value={selectedCategory}
342 onChange={(e) => setSelectedCategory(e.target.value)}
343 >
344 <option value="all">全部分类</option>
345 {categories.map(cat => (
346 <option key={cat} value={cat}>{cat}</option>
347 ))}
348 </select>
349 </div>
350
351 <div className="filter-group">
352 <label>是否有图片</label>
353 <select
354 value={hasImage}
355 onChange={(e) => setHasImage(e.target.value)}
356 >
357 <option value="all">全部</option>
358 <option value="yes">有图片</option>
359 <option value="no">无图片</option>
360 </select>
361 </div>
362
363 <div className="filter-group">
364 <label>最低价格</label>
365 <input
366 type="number"
367 placeholder="¥0"
368 value={minPrice}
369 onChange={(e) => setMinPrice(e.target.value)}
370 />
371 </div>
372
373 <div className="filter-group">
374 <label>最高价格</label>
375 <input
376 type="number"
377 placeholder="¥999"
378 value={maxPrice}
379 onChange={(e) => setMaxPrice(e.target.value)}
380 />
381 </div>
382 </div>
383
384 <div className="stats">
385 <div className="stat-item">
386 <span>总菜品:</span>
387 <span className="stat-number">{stats.total}</span>
388 </div>
389 <div className="stat-item">
390 <span>有图片:</span>
391 <span className="stat-number">{stats.withImage}</span>
392 </div>
393 <div className="stat-item">
394 <span>当前显示:</span>
395 <span className="stat-number">{stats.filtered}</span>
396 </div>
397 </div>
398 </div>
399
400 {filteredDishes.length === 0 ? (
401 <div className="empty-state">
402 <div className="empty-state-icon">🔍</div>
403 <div>没有找到符合条件的菜品</div>
404 </div>
405 ) : (
406 <div className="dishes-grid">
407 {filteredDishes.map(dish => (
408 <DishCard key={dish.id} dish={dish} onCopy={showToast} />
409 ))}
410 </div>
411 )}
412 </div>
413 );
414 }
415
416 function DishCard({ dish, onCopy }) {
417 const imageUrl = dish.image
418 ? `${REPO_BASE}/media/${dish.id}.${dish.image.endsWith('.jpg') || dish.image.endsWith('.jpeg') ? 'jpg' : 'png'}`
419 : null;
420
421 const price = dish.specs[0]?.price || 0;
422
423 const handleImageClick = () => {
424 const imageName = imageUrl ? imageUrl.split('/').pop() : `${dish.id}.png`;
425 navigator.clipboard.writeText(imageName).then(() => {
426 onCopy(`已复制: ${imageName}`);
427 }).catch(() => {
428 onCopy('复制失败');
429 });
430 };
431
432 return (
433 <div className="dish-card">
434 {imageUrl ? (
435 <img
436 src={imageUrl}
437 alt={dish.name}
438 className="dish-image"
439 onClick={handleImageClick}
440 onError={(e) => {
441 e.target.style.display = 'none';
442 e.target.nextSibling.style.display = 'flex';
443 }}
444 />
445 ) : null}
446 <div
447 className="no-image"
448 style={{ display: imageUrl ? 'none' : 'flex' }}
449 onClick={handleImageClick}
450 >
451 暂无图片
452 </div>
453
454 <div className="dish-content">
455 <div className="dish-header">
456 <div className="dish-name">{dish.name}</div>
457 <div className="dish-price">¥{price}</div>
458 </div>
459
460 <div className="dish-info">
461 <span className="dish-category">{dish.category}</span>
462 <span>{dish.unit}</span>
463 </div>
464
465 {dish.methods && dish.methods.length > 0 && (
466 <div className="dish-methods">
467 {dish.methods.map((method, idx) => (
468 <span key={idx} className="method-tag">
469 {method.name}
470 </span>
471 ))}
472 </div>
473 )}
474 </div>
475 </div>
476 );
477 }
478
479 ReactDOM.render(<App />, document.getElementById('root'));
480 </script>
481</body>
482</html>
483