跳至主要內容

13 / WORK / CASE STUDY

環飽 EcoBǎo 剩食訂購平台

媒合店家餘食與消費者的剩食訂購平台,消費者與店家雙端;近期重構為純前端 demo 並統一設計系統,作為公開作品集。

TYPE
FULL STACK
SCOPE
剩食平台 · 現代化重構
STACK
REACT · VITE · TAILWIND · REACT NATIVE · DJANGO · DRF
LINKS
LIVE

01 / PROBLEM

環飽 EcoBǎo 是一個以「剩食」為題的訂購媒合平台——店家把當日未售完但仍新鮮的餐點,以即期優惠或驚喜福袋的形式折扣出售,消費者於指定時段到店自取,藉此減少food waste。專案分為三個 codebase:React 網頁前端、React Native(Expo)行動 App 與 Django REST 後端,提供消費者與店家兩端介面。這輪工作的目標,是把這個學生時期的專題「翻新成能公開展示的作品集」。

02 / CONSTRAINTS

  • 網頁前端原本綁死後端:透過 axios 打一組 ngrok 後端網址,一旦後端不在就整站空白,無法獨立展示。要當作品集 demo,必須讓它能脫離後端、部署到 GitHub Pages 靜態託管。
  • 既有前端同時混用 MUI、Ant Design、Bootstrap 與 styled-components 四套樣式系統,視覺不一致、bundle 也肥。
  • 三個 repo 都要公開,後端原本把 SECRET_KEY、資料庫帳密與郵件設定寫死在程式碼裡,直接開源等於外洩設定。

03 / ARCHITECTURE

前端從 Create React App 遷移到 Vite,並移除四套 UI 庫、收斂到單一 Tailwind 設計系統。設計語言以 UI/UX 評估重建為「Eco-Fresh(清新永續)」:翠綠主色 + 食物暖橙點綴、Noto Sans/Serif TC 中文字體,token 以 CSS 變數驅動、Web 與 App 共用,並支援深色模式與 prefers-reduced-motion。

為了脫離後端,建立一層純前端 mock API + seed 資料(店家、剩食商品、活動、評價、訂單、會員),以模擬延遲與 localStorage 持久化重現購物車、下單與取貨碼流程;整站改用 HashRouter 以相容 GitHub Pages,並以 GitHub Actions 自動建置部署。

後端(Django 4 + DRF)把 SECRET_KEY、資料庫、郵件與 API 金鑰等機敏設定全數抽離為環境變數並附 .env.example;補上 requirements 與完整 README,移除測試殘檔與未使用模組,並將誤入版控的 __pycache__ 與資產取消追蹤。行動端(Expo / React Native)則導入共用設計 token 與 UI 原子元件、統一導覽與樣式。

04 / RESPONSIBILITIES

本輪現代化重構(前端 Vite 遷移與 Eco-Fresh 設計系統、純前端 mock/seed 與 GitHub Pages 部署、後端機敏設定抽離與文件、App 設計語言套用)由我主導完成;原始平台為在學期間的團隊專題與競賽作品。

05 / CHALLENGES → SOLUTIONS

CHALLENGE

前端整站綁死後端,後端一離線就空白,無法當獨立 demo。

SOLUTION

在資料層與畫面之間插入一層 mock API,對外簽名與真實後端一致,底層改讀前端 seed 並以 localStorage 持久化;購物車、結帳、取貨碼、店家後台儀表板都能在無後端下完整跑完,直接靜態部署。

CHALLENGE

四套 UI 樣式庫並存,視覺破碎、維護成本高。

SOLUTION

先以 UI/UX 評估定出 Eco-Fresh 設計 token,再自建一套 Tailwind 元件庫(按鈕、卡片、標籤、對話框、分頁、評分、骨架⋯)逐頁替換,最終移除 MUI/Antd/Bootstrap/styled-components,收斂為單一設計系統。

CHALLENGE

後端要公開,但機敏設定散落在原始碼中。

SOLUTION

把 SECRET_KEY、資料庫、郵件與金鑰全部抽成環境變數並提供 .env.example 範本,補上 .gitignore、requirements 與 README,並將原本誤入版控的快取與資產取消追蹤——讓 repo 能安全開源。

06 / SYSTEM FACTS

3

Web · App · 後端 repo

2

消費者 / 店家雙端

4→1

UI 樣式庫收斂

SDGs 2·12

對應永續目標

07 / LESSONS

把舊專案變成能公開展示的作品集,最關鍵的往往不是重畫畫面,而是先讓它「能獨立運作」並「機敏資訊乾淨」——解耦後端與抽離設定,比視覺翻新更早要處理。

設計 token 先行,web 與 app 才能共享同一套視覺語言;一致性來自系統,而不是逐頁手刻。

08 / SCREENS

以下畫面皆為示範(mock)資料,不含任何真實使用者資料。

環飽消費者首頁全頁:惜食主視覺與搜尋列、平台影響力計數、食物分類磚、今日精選剩食折扣卡、三步驟說明與高評價店家
消費者首頁全頁。頂端三個影響力數字不是營運數據,而是 getImpactStats() 從 8 家種子店家即時換算出來的(每份餐點 2.5 公斤 CO₂);少數圖磚落回品牌漸層,是 Unsplash 沒在時限內回應時 SmartImage 的預設行為,不是截圖失誤。
店家後台營運總覽:今日訂單/營收/已拯救餐點/評分四張 KPI 卡、近 7 日營收長條圖、驚喜福袋佔比甜甜圈、熱銷商品橫條、近期回饋與待處理訂單
店家後台總覽——平台的另一面。KPI、近 7 日長條圖與熱銷排行都由同一份 deterministic 生成的訂單資料推導,所以數字彼此對得上;右上角那幾個 +12% / +8% 的趨勢徽章則是寫死的示意值,沒有比較基準。圖表由 recharts 繪製;畫面其餘部分全是自建的 Tailwind 元件,MUI / Ant Design / Bootstrap / styled-components 都已不在相依清單裡。
逛剩食頁:左欄分類與「只看驚喜福袋」篩選、排序下拉,右側 20 張剩食商品卡,標示原價、折扣價、剩餘份數與取貨時段
逛剩食:20 筆種子商品在分類篩選、「只看驚喜福袋」與排序之下的完整列表。每張卡片同時給原價、折扣價、剩餘份數與取貨時段——對剩食來說時間壓力就是核心資訊,所以放在卡面而不是詳情頁。
結帳頁:取貨人資訊表單(姓名、電話、Email 已帶入)、取貨時段選擇、現場付款/貨到付款單選,右側訂單摘要三項商品合計 NT$287
結帳頁。三項商品與 NT$287 小計不是擺拍——截圖腳本在店家頁真的按了三次「加入購物車」,狀態經 localStorage 一路帶到這裡;取貨人資訊由 demo 帳號自動帶入。整條購物流程沒有後端,src/lib/api.js 這層 372 行的假 API 把固定 fixture 包成有延遲的非同步介面。
390px 寬的「我的訂單」頁:進行中(2)/ 已完成(3)分頁、兩張訂單卡含取貨碼與金額,下方為深綠色頁尾
同一份訂單頁在 390px 下的樣子。整個專案只有一份 src/index.css——行動版沒有第二套樣式表,是同一組 Tailwind token 直接重排。取貨碼是這條流程的實體交付物:到店報碼取餐,線上到線下的交接就在這裡。
Expo React Native 行動端店家畫面:即期 5 折標籤、4.8(326)評分、今日取餐時段,以及橫向捲動的精選惜食折扣卡
Expo(React Native)行動端的店家畫面。兩點如實註記:這是 expo export --platform web 的網頁渲染,不是 iOS/Android 裝置截圖;而該 repo 目前所有店家共用同一張市場照、所有商品共用同一張漢堡圖——佔位素材沒有為了拍照被換掉。
DRF 可瀏覽 API 的 GET /store_sch/ 回應:HTTP 200,五家種子店家的 JSON,含 upid、type、city / area、address 與 lng / lat 座標
DRF 可瀏覽 API:帶著真的 JWT 打 GET /store_sch/,拿回 5 家種子店家,含註冊時由 Google Geocoding 寫入的 lng / lat。這個專案只認 JWT、沒有 session 登入,沒帶 token 的瀏覽器直接吃 401。後端其餘幾乎全是 POST-only 的 @action,截圖照不出來——那是下面五張圖的工作。

09 / ARCHITECTURE DIAGRAMS

以下架構圖皆由專案原始碼推導繪製,每個節點都可回溯到實際檔案。圖較寬,可左右捲動,或點擊開啟原尺寸 SVG。

訂單狀態機:未接單 → 已接單 → 未取餐 → 已完成,另有取消與硬刪除的終止路徑,每條轉移標註對應的 POST 端點與 order/views.py 行號
訂單生命週期——REST 介面藏起來的那一半。order_order.status 是沒有 choices 的 CharField(50),五個狀態全是中文字串字面值;圖上的箭頭是意圖,不是約束:除了 /order/delete/,每個 action 都以 Order.objects.get(oid=...) 直接覆寫狀態,不檢查呼叫者是不是這張訂單的主人,任何狀態都能跳到任何狀態。complete_time 也從頭到尾沒有任何程式寫入。
POST /order/add/ 的序列圖:JWT 解析使用者後,在 transaction.atomic 內查購物車、加總、產生 oid、寫入訂單與明細與付款、刪除購物車列,並標出從未被呼叫的寄信分支
系統裡最重要的一次寫入 POST /order/add/,逐步對照 order/views.py:186–274。整段包在 transaction.atomic 裡,但有三件事不是交易能救的:oid 取自即時 COUNT(*)(兩筆併發結帳會撞號)、庫存只在 /cart/add/ 檢查過而結帳時從未扣減、信用卡號與安全碼原文寫進 order_orderpayment。右邊通往 notice.app.Email 的線是斷的:__mail() 沒有任何呼叫者,郵件通知在這個 codebase 裡從來沒有真的接上。
API 權限分層圖:SimpleRouter 的 13 個註冊與 4 條顯式路由,依 AllowAny 匿名層、IsAuthenticated 會員層、view 內手寫的店家守衛層、Django admin 層分組,虛線標出會漏的節點
整個 API 表面依「實際上誰擋得住」分層,而不是依模組分層。第三層最值得看:店家身分不是用 permission class 擋的,而是在 view 裡手寫 Store.objects.filter(upid_id=request.user.uid).count() == 1;虛線節點是它自己會漏的地方——商品 upload/change 宣告成 IsAuthenticated | AllowAny,那個 OR 讓匿名呼叫者一樣過關。
核心領域 ER 圖:會員、店家、營業時間、商品、評價、購物車、訂單、訂單明細、付款與取消等資料表的實際欄位名與主鍵外鍵關係
核心領域 ER。表名與欄位名取自實際跑起來的 demo.sqlite3(PRAGMA table_info / foreign_key_list),再與 models 和 migrations 交叉比對,所以畫的是資料庫真的有的欄位,不是宣告過的欄位。幾個設計代價一眼看得到:store.upid_id 同時是 PK 和 FK(一個會員只能開一家店)、price 與 quantity 都存成字串、order_ordercheck 有表也有 migration,卻沒有任何程式讀寫它。
Django 4.1 + DRF 系統架構圖:入口點、middleware 鏈、urls 路由、DRF 認證與權限預設、四個業務 app、Google Geocoding 與 geopy 外部呼叫、MySQL / SQLite 切換,以及以虛線標示的未接線元件
Django 4.1 + DRF 的實際接線:middleware 順序、SimpleRouter 的 13 個註冊、JWT 預設(HS256、USER_ID_FIELD=uid、access token 60 分鐘)、Geocoding 與 in-process 的 geopy 距離計算,以及 MySQL / SQLite 由 settings 模組決定。虛線的四個節點是「宣告了但沒作用」:asgi.py 沒有任何 async consumer、CsrfViewMiddleware 被註解掉、notice/ 郵件模組不在 INSTALLED_APPS 裡。這個 repo 沒有 Celery、沒有佇列也沒有排程——所有工作都在請求執行緒裡同步跑完。

NEXT

四時煮食時 — AI 食療推薦與健康管理平台