tbdavid2019 commited on
Commit
08788ee
·
1 Parent(s): 399a360

Update space

Browse files
Files changed (2) hide show
  1. README.md +56 -0
  2. index.html +386 -18
README.md CHANGED
@@ -9,3 +9,59 @@ short_description: 店面脈搏 即時監控多店面的google maps評價!
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
12
+
13
+
14
+ ---
15
+
16
+ # 多店面管理評價速查 �
17
+
18
+ 專為連鎖店面管理而設計的評價監控工具,讓你快速掌握各分店的客戶評價狀況!
19
+
20
+ ## � 核心功能
21
+ - 🔍 **店名搜尋**:直接輸入品牌名稱(如「麥當勞」、「星巴克」)即可找到所有分店
22
+ - 📍 **GPS 定位**:自動取得用戶位置,優先顯示附近的分店
23
+ - ⭐ **評論展示**:每間分店顯示最多 5 則最新評論,完整掌握顧客反饋
24
+ - 🚨 **低分警示**:3 星以下評論自動以橘色底色標示,快速識別問題分店
25
+ - 🗺️ **地圖整合**:結合 Google Maps 視覺化呈現,點擊卡片即可查看位置
26
+ - ⚡ **即時載入**:搜尋過程顯示動態載入效果,提升使用體驗
27
+
28
+ ## 🛠️ 技術架構
29
+ - **前端**:HTML + CSS + JavaScript(原生)
30
+ - **後端**:Python Flask
31
+ - **API**:Google Maps Text Search API + Places Details API
32
+ - **安全性**:API Key 後端管理,前端不暴露敏感資訊
33
+
34
+ ## 🚀 部署方式
35
+
36
+ ### 本地開發
37
+ ```bash
38
+ # 1. 安裝依賴
39
+ pip install -r requirements.txt
40
+
41
+ # 2. 設定環境變數
42
+ cp example.env .env
43
+ # 編輯 .env 檔案,填入你的 GOOGLE_MAPS_API_KEY
44
+
45
+ # 3. 啟動服務
46
+ python app.py
47
+ ```
48
+
49
+ ### Hugging Face Spaces 部署
50
+ 1. Fork 此專案到你的 Hugging Face 帳號
51
+ 2. 在 Space Settings → Repository secrets 中設定:
52
+ - `GOOGLE_MAPS_API_KEY`: 你的 Google Maps API 金鑰
53
+ 3. 系統會自動部署並啟動
54
+
55
+ ## 🔐 API Key 申請
56
+ 1. 前往 [Google Cloud Console](https://console.cloud.google.com/)
57
+ 2. 建立新專案或選擇現有專案
58
+ 3. 啟用以下 API:
59
+ - Maps JavaScript API
60
+ - Places API
61
+ - Geocoding API
62
+ 4. 建立 API 金鑰並設定適當的使用限制
63
+
64
+ ## 💡 使用建議
65
+ - 建議 API 金鑰設定每日使用量限制,避免超額費用
66
+ - 搜尋結果限制為 10 筆,在功能性與 API 用量間取得平衡
67
+ - 支援 Enter 鍵快速搜尋,提升操作效率
index.html CHANGED
@@ -1,19 +1,387 @@
1
- <!doctype html>
2
  <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
  <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>多店面管理評價速查</title>
6
+ <style>
7
+ body {
8
+ font-family: "Segoe UI", sans-serif;
9
+ margin: 0;
10
+ background: #fff1e0;
11
+ }
12
+ header {
13
+ background: linear-gradient(to right, #fff4e6, #ff6f61);
14
+ color: white;
15
+ padding: 2rem;
16
+ margin: 2rem auto 0;
17
+ width: 90%;
18
+ max-width: 1000px;
19
+ border-radius: 20px;
20
+ text-align: center;
21
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
22
+ }
23
+ header h2 {
24
+ font-size: 2rem;
25
+ margin-bottom: 0.5rem;
26
+ }
27
+ .container {
28
+ background: white;
29
+ margin: 2rem auto;
30
+ padding: 2rem;
31
+ width: 90%;
32
+ max-width: 1000px;
33
+ border-radius: 12px;
34
+ box-shadow: 0 10px 30px rgba(0,0,0,0.1);
35
+ }
36
+ #controls {
37
+ display: flex;
38
+ flex-wrap: wrap;
39
+ gap: 1rem;
40
+ margin-bottom: 1rem;
41
+ align-items: center;
42
+ justify-content: space-between;
43
+ }
44
+ input, select, button {
45
+ padding: 10px;
46
+ font-size: 1rem;
47
+ border: 1px solid #ccc;
48
+ border-radius: 8px;
49
+ }
50
+ button {
51
+ background: linear-gradient(to right, #667eea, #764ba2);
52
+ color: white;
53
+ cursor: pointer;
54
+ border: none;
55
+ }
56
+ #map {
57
+ height: 400px;
58
+ margin-top: 1rem;
59
+ border-radius: 12px;
60
+ }
61
+ #results {
62
+ margin-top: 2rem;
63
+ }
64
+ .card-container {
65
+ display: grid;
66
+ grid-template-columns: repeat(3, 1fr);
67
+ gap: 1.5rem;
68
+ margin-top: 1rem;
69
+ }
70
+ .card {
71
+ background: #fff;
72
+ border-radius: 10px;
73
+ padding: 1rem;
74
+ box-shadow: 0 4px 10px rgba(0,0,0,0.1);
75
+ transition: transform 0.3s ease;
76
+ cursor: pointer;
77
+ }
78
+ .card:hover, .card.active {
79
+ transform: scale(1.05);
80
+ box-shadow: 0 6px 15px rgba(0,0,0,0.2);
81
+ }
82
+ .card h4 {
83
+ margin: 0 0 0.5rem 0;
84
+ }
85
+ .card p {
86
+ margin: 0.25rem 0;
87
+ }
88
+ .reviews-container {
89
+ margin-top: 1rem;
90
+ border-top: 1px solid #eee;
91
+ padding-top: 1rem;
92
+ }
93
+ .reviews-container h5 {
94
+ margin: 0 0 0.5rem 0;
95
+ font-size: 1rem;
96
+ }
97
+ .reviews-container ul {
98
+ list-style: none;
99
+ padding: 0;
100
+ margin: 0;
101
+ }
102
+ .reviews-container li {
103
+ margin-bottom: 1rem;
104
+ font-size: 0.9rem;
105
+ padding-bottom: 0.5rem;
106
+ border-bottom: 1px solid #f5f5f5;
107
+ }
108
+ .reviews-container li:last-child {
109
+ border-bottom: none;
110
+ }
111
+ .reviews-container li p {
112
+ margin: 0.2rem 0;
113
+ }
114
+ .low-rating-review {
115
+ background-color: #ff6f61;
116
+ border: 1px solid #ffdde1;
117
+ padding: 0.5rem;
118
+ border-radius: 8px;
119
+ }
120
+ .custom-infowindow {
121
+ background: url('https://raw.githubusercontent.com/elvis860812/image-assets/main/custom-speech-bubble.png') no-repeat center center;
122
+ background-size: contain;
123
+ width: 240px;
124
+ height: 140px;
125
+ display: flex;
126
+ flex-direction: column;
127
+ justify-content: center;
128
+ padding: 1.5rem;
129
+ font-size: 14px;
130
+ font-weight: bold;
131
+ position: relative;
132
+ text-align: center;
133
+ }
134
+ .custom-infowindow .close-btn {
135
+ position: absolute;
136
+ top: 6px;
137
+ right: 10px;
138
+ cursor: pointer;
139
+ font-weight: bold;
140
+ font-size: 18px;
141
+ }
142
+ /* Loader styles */
143
+ .loader-overlay {
144
+ position: fixed;
145
+ top: 0;
146
+ left: 0;
147
+ width: 100%;
148
+ height: 100%;
149
+ background: rgba(255, 255, 255, 0.8);
150
+ display: flex;
151
+ justify-content: center;
152
+ align-items: center;
153
+ z-index: 1000;
154
+ }
155
+ .loader-content {
156
+ text-align: center;
157
+ padding: 2rem;
158
+ background: #fff;
159
+ border-radius: 15px;
160
+ box-shadow: 0 5px 15px rgba(0,0,0,0.1);
161
+ }
162
+ .spinner {
163
+ border: 8px solid #f3f3f3; /* Light grey */
164
+ border-top: 8px solid #ff6f61; /* Primary color */
165
+ border-radius: 50%;
166
+ width: 60px;
167
+ height: 60px;
168
+ animation: spin 1s linear infinite;
169
+ margin: 0 auto 1rem;
170
+ }
171
+ @keyframes spin {
172
+ 0% { transform: rotate(0deg); }
173
+ 100% { transform: rotate(360deg); }
174
+ }
175
+ </style>
176
+ </head>
177
+ <body>
178
+ <header>
179
+ <h2>🍽️ 多店面管理評價速查</h2>
180
+ <p>輸入店名,即時掌握各分店評價</p>
181
+ </header>
182
+
183
+ <div id="loader" class="loader-overlay" style="display: none;">
184
+ <div class="loader-content">
185
+ <div class="spinner"></div>
186
+ <p>正在搜尋,請稍候...</p>
187
+ </div>
188
+ </div>
189
+
190
+ <div class="container">
191
+ <div id="controls">
192
+ <input id="searchQuery" type="text" placeholder="請輸入店家名稱(例:麥當勞)" style="flex:1;">
193
+ <button onclick="searchStores()">搜尋</button>
194
+ </div>
195
+
196
+ <div id="map"></div>
197
+ <div id="results">
198
+ <h3>推薦店家</h3>
199
+ <div class="card-container" id="restaurantCards">
200
+ <p>請輸入地址開始搜尋附近餐廳</p>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <script>
206
+ let map, geocoder, placesService; // 將 placesService 宣告在更廣的作用域
207
+ const markers = [];
208
+ const markerMap = new Map();
209
+ let activeInfoWindow = null;
210
+ let userLocation = null;
211
+
212
+ function initMap() {
213
+ const defaultLoc = { lat: 25.0330, lng: 121.5654 }; // 預設台北市中心
214
+ map = new google.maps.Map(document.getElementById("map"), {
215
+ zoom: 12,
216
+ center: defaultLoc
217
+ });
218
+ geocoder = new google.maps.Geocoder();
219
+ placesService = new google.maps.places.PlacesService(map);
220
+
221
+ // 頁面載入時請求 GPS 位置
222
+ if (navigator.geolocation) {
223
+ navigator.geolocation.getCurrentPosition(position => {
224
+ userLocation = {
225
+ lat: position.coords.latitude,
226
+ lng: position.coords.longitude
227
+ };
228
+ map.setCenter(userLocation);
229
+ map.setZoom(15);
230
+ new google.maps.Marker({ map, position: userLocation, icon: 'https://maps.google.com/mapfiles/ms/icons/blue-dot.png', title: '您的位置' });
231
+ }, () => {
232
+ console.log("使用者拒絕提供位置資訊。");
233
+ });
234
+ }
235
+
236
+ // 讓使用者可以在輸入框中按下 Enter 鍵來觸發搜尋
237
+ document.getElementById('searchQuery').addEventListener('keyup', function(event) {
238
+ if (event.key === 'Enter') {
239
+ event.preventDefault(); // 防止可能的表單提交行為
240
+ searchStores();
241
+ }
242
+ });
243
+ }
244
+
245
+ async function searchStores() {
246
+ const query = document.getElementById("searchQuery").value;
247
+ if (!query) return alert("請輸入店家名稱");
248
+
249
+ const loader = document.getElementById('loader');
250
+ loader.style.display = 'flex';
251
+
252
+ let searchUrl = `/api/search?query=${encodeURIComponent(query)}`;
253
+ if (userLocation) {
254
+ searchUrl += `&lat=${userLocation.lat}&lng=${userLocation.lng}`;
255
+ }
256
+
257
+ try {
258
+ const response = await fetch(searchUrl);
259
+ const data = await response.json();
260
+
261
+ if (!response.ok) {
262
+ throw new Error(data.error || '伺服器錯誤');
263
+ }
264
+
265
+ const container = document.getElementById("restaurantCards");
266
+ container.innerHTML = "";
267
+ markers.forEach(m => m.setMap(null));
268
+ markers.length = 0;
269
+ markerMap.clear();
270
+
271
+ if (activeInfoWindow) {
272
+ activeInfoWindow.close();
273
+ activeInfoWindow = null;
274
+ }
275
+
276
+ if (data.center) {
277
+ const center = new google.maps.LatLng(data.center.lat, data.center.lng);
278
+ map.setCenter(center);
279
+ }
280
+
281
+ if (data.results && data.results.length > 0) {
282
+ data.results.forEach(place => {
283
+ createMarker(place);
284
+ createCard(place);
285
+ });
286
+ } else {
287
+ container.innerHTML = "<p>找不到符合條件的店家。</p>";
288
+ }
289
+
290
+ } catch (error) {
291
+ console.error("搜尋失敗:", error);
292
+ alert("搜尋失敗:" + error.message);
293
+ } finally {
294
+ loader.style.display = 'none';
295
+ }
296
+ }
297
+
298
+ function createCard(place) {
299
+ const container = document.getElementById("restaurantCards");
300
+ const card = document.createElement("div");
301
+ card.className = "card";
302
+
303
+ let reviewsHtml = '';
304
+ if (place.reviews && place.reviews.length > 0) {
305
+ reviewsHtml = '<div class="reviews-container">';
306
+ reviewsHtml += '<h5>最新評論:</h5>';
307
+ reviewsHtml += '<ul>';
308
+ place.reviews.forEach(review => {
309
+ const ratingClass = review.rating <= 3 ? 'class="low-rating-review"' : '';
310
+ reviewsHtml += `
311
+ <li ${ratingClass}>
312
+ <p><strong>${review.author_name}</strong> (⭐ ${review.rating})</p>
313
+ <p style="white-space: pre-wrap;">${review.text}</p>
314
+ </li>
315
+ `;
316
+ });
317
+ reviewsHtml += '</ul></div>';
318
+ }
319
+
320
+ card.innerHTML = `
321
+ <h4>${place.name}</h4>
322
+ <p>⭐ ${place.rating || '無評分'} (${place.user_ratings_total || 0} 則評論)</p>
323
+ <p>📍 <a href="${place.url}" target="_blank">${place.vicinity}</a></p>
324
+ ${reviewsHtml}
325
+ `;
326
+
327
+ card.onclick = () => {
328
+ const marker = markerMap.get(place.place_id);
329
+ if (marker) {
330
+ new google.maps.event.trigger(marker, 'click');
331
+ document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
332
+ card.classList.add('active');
333
+ }
334
+ };
335
+ container.appendChild(card);
336
+ }
337
+
338
+ function createMarker(place) {
339
+ const marker = new google.maps.Marker({
340
+ map,
341
+ position: place.geometry.location,
342
+ title: place.name
343
+ });
344
+
345
+ const infoWindowContent = `
346
+ <div class="custom-infowindow">
347
+ <span class="close-btn" onclick="this.parentElement.parentElement.style.display='none';">&times;</span>
348
+ <strong>${place.name}</strong><br>
349
+ ⭐ ${place.rating || 'N/A'} | 評論: ${place.user_ratings_total || 0}<br>
350
+ <a href="${place.url}" target="_blank">在 Google 地圖上查看</a>
351
+ </div>
352
+ `;
353
+
354
+ const infoWindow = new google.maps.InfoWindow({
355
+ content: infoWindowContent,
356
+ ariaLabel: place.name,
357
+ });
358
+
359
+ marker.addListener("click", () => {
360
+ if (activeInfoWindow) {
361
+ activeInfoWindow.close();
362
+ }
363
+ infoWindow.open({
364
+ anchor: marker,
365
+ map,
366
+ });
367
+ activeInfoWindow = infoWindow;
368
+ map.panTo(marker.getPosition());
369
+ });
370
+
371
+ markers.push(marker);
372
+ markerMap.set(place.place_id, marker);
373
+ }
374
+
375
+ // 當用戶點擊地圖其他地方時,關閉 InfoWindow
376
+ google.maps.event.addDomListener(map, 'click', () => {
377
+ if (activeInfoWindow) {
378
+ activeInfoWindow.close();
379
+ activeInfoWindow = null;
380
+ }
381
+ document.querySelectorAll('.card').forEach(c => c.classList.remove('active'));
382
+ });
383
+
384
+ </script>
385
+ <script async defer src="https://maps.googleapis.com/maps/api/js?key={{ api_key }}&callback=initMap&libraries=places,marker&v=weekly"></script>
386
+ </body>
387
+ </html>