Last active 2 weeks ago

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