From 2db5ceaf4629582a0bf2db7e39cd932e7b30d509 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 9 Apr 2026 08:47:06 +0800 Subject: [PATCH 01/12] docs: add PRD --- docs/PRD.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/PRD.md diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 00000000..ecebb389 --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,55 @@ +# 產品需求文件 (PRD) - 食譜收藏夾系統 + +## 1. 專案概述 +- **背景與動機**:現代人注重飲食,常在網路上看到喜歡的食譜,但缺乏一個集中的地方進行收藏與整理。有時打開冰箱看著現有的食材,卻沒有靈感不知道能變出什麼菜。本系統旨在提供一個方便儲存、管理並能透過現有食材找尋靈感的食譜管理平台。 +- **目標用戶**: + - **一般人**:需要收藏食譜、找尋料理靈感的使用者。 + - **管理員**:維護平台內容、管理用戶與系統健康度的角色。 +- **核心價值主張**:提供一個直覺易用的個人食譜收藏空間,並支援強大的「食材組合」搜尋功能,解決「不知道今天要煮什麼」的核心痛點。 + +## 2. 功能需求 +系統涵蓋以下核心功能,各項功能的使用者故事 (User Story) 如下: + +1. **儲存食譜** + - 作為 **一般人**,我希望 **能夠將自己喜歡或創作的食譜(包含名稱、食材、步驟)儲存到系統中**,以便 **在日後準備料理時能快速查閱**。 +2. **瀏覽、搜尋食譜** + - 作為 **一般人**,我希望 **能夠透過關鍵字或分類來瀏覽與搜尋食譜**,以便 **快速找到我想做的菜餚**。 +3. **從食材組合搜尋食譜** + - 作為 **一般人**,我希望 **能夠輸入我手邊現有的多樣食材**,以便 **系統能過濾並建議我可以直接用這些食材做出的食譜**。 +4. **使用者註冊與身份管理** + - 作為 **一般人**,我希望 **能夠註冊個人帳號並登入系統**,以便 **我能擁有專屬的食譜收藏空間,不會與他人混淆**。 +5. **後台管理與內容維護** + - 作為 **管理員**,我希望 **能夠檢視全站食譜內容與用戶清單,並得以下架不當內容**,以便 **維持平台內容的品質與秩序**。 + +## 3. 非功能需求 +- **技術限制**: + - 框架與後端:基於 Web 服務,後端須使用 Python `Flask` 框架。 + - 前端與樣式:HTML 模板使用 `Jinja2` 渲染,搭配 Vanilla CSS 與 JavaScript。 + - 資料庫:採用 `SQLite` 關聯式資料庫。 +- **效能與安全考量**: + - **安全性**:必須實作密碼雜湊加密機制(例如 bcrypt),避免明碼儲存;針對表單輸入須防範 SQL Injection 及 XSS 攻擊。 + - **效能**:「食材組合搜尋」可能涉及多對多或多條件複雜查詢,應優化資料庫關聯表與索引規劃(Schema Design),確保在一般資料量下,搜尋回應時間能維持在合理範圍以內。 + +## 4. MVP 範圍 (Minimum Viable Product) +- **Must Have (必須有)**: + - 用戶註冊、登入、登出系統。 + - 基礎食譜 CRUD(新增、檢視、編輯、刪除),包含食譜名稱、所需食材與步驟。 + - 基本關鍵字搜尋食譜功能。 +- **Should Have (應該有)**: + - 食材組合過濾搜尋功能(輸入多個食材即可找出包含這些食材的食譜)。 + - 對食譜做簡單的隱私分類(公開/私有)。 + - 各用戶身分區分(區別一般用戶與管理員),且管理員有權限刪除全站資料。 +- **Nice to Have (加分項)**: + - 支援食譜封面圖片上傳與預覽。 + - 將別人的公開食譜一鍵「收藏」到自己的清單。 + - 食譜評價或留言系統。 + +## 5. 專案成員與分工 + +| 角色 | 姓名 / 負責人 | 負責範圍 | 備註 | +| --- | --- | --- | --- | +| 專案經理 (PM) | | | | +| UI/UX 設計師 | | | | +| 前端工程師 | | | | +| 後端工程師 | | | | +| 測試工程師 (QA) | | | | From 0bb3120e0d305c7f1d3d04f2fc3abf2955a4391b Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 9 Apr 2026 08:52:37 +0800 Subject: [PATCH 02/12] docs: add system architecture --- docs/ARCHITECTURE.md | 93 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/ARCHITECTURE.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..79cc1940 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,93 @@ +# 系統架構文件 (Architecture) - 食譜收藏夾系統 + +## 1. 技術架構說明 + +本專案採用傳統的伺服器端渲染(Server-Side Rendering, SSR)架構進行開發,由後端框架一併處理業務邏輯與畫面渲染(不採用前後端分離)。 + +### **1.1 選用技術與原因** +- **後端框架:Python + Flask** + - **原因**:Flask 是一套輕量級且靈活的框架,非常適合快速開發 MVP(最小可行產品)與中小型專案。它能輕鬆處理 HTTP 請求及路由配置。 +- **模板引擎:Jinja2** + - **原因**:與 Flask 高度整合,能夠在伺服器端將資料庫查詢到的變數(如食譜清單)直接注入到 HTML 頁面中,這對於 SEO 非常有利且易於開發上手。 +- **資料庫:SQLite** + - **原因**:設定簡單,無需另外架設與維護資料庫伺服器,資料是以單一檔案(如 `database.db`)的形式存在這台主機上,非常輕量,適合開發初期與小規模資料量。 + +### **1.2 Flask MVC 模式說明** +雖然 Flask 本身沒有強制的目錄架構,但我們依循經典的 MVC(Model-View-Controller)模式概念來組織程式碼: +- **Model(模型)**:負責與 SQLite 資料庫溝通,處理資料的存取與商業邏輯(例如:寫入新食譜、查詢包含特定食材的食譜)。 +- **View(視圖)**:負責使用者介面(UI)的呈現。在此系統中對應為放在 `templates/` 資料夾裡的 Jinja2 HTML 檔案。 +- **Controller(控制器)**:在這裡對應為 Flask 的 **Routes(路由)**,負責接收使用者的請求、向 Model 要求資料處理,接著將資料丟給 View (Jinja2) 去渲染,最終組成完整的網頁回傳。 + +--- + +## 2. 專案資料夾結構 + +以下是本專案的目錄結構初步規劃,將模組化拆分邏輯與視圖: + +```text +web_app_development/ +├── app.py # 應用程式入口點,負責啟動 Flask 伺服器 +├── requirements.txt # Python 套件相依清單 (開發時產出) +├── instance/ # 不進入版控的特定環境檔案 +│ └── database.db # SQLite 資料庫儲存檔 +├── app/ # 核心專案內文 +│ ├── __init__.py # 建立 Flask App、註冊設定檔 +│ ├── models/ # 【Model】資料庫結構定義 +│ │ ├── __init__.py +│ │ ├── user.py # 用戶資料表模型 +│ │ └── recipe.py # 食譜、食材、關聯表模型 +│ ├── routes/ # 【Controller】路由處置 +│ │ ├── __init__.py +│ │ ├── auth.py # 註冊、登入與權限路由 +│ │ └── recipe.py # 食譜 CRUD 及查詢路由 +│ ├── templates/ # 【View】Jinja2 HTML 模板 +│ │ ├── base.html # 共用基本排版 (含 navbar, footer 等) +│ │ ├── index.html # 首頁 (搜尋與展示) +│ │ ├── auth/ # 用戶相關頁面 (login.html, register.html) +│ │ └── recipe/ # 食譜相關頁面 (list.html, detail.html, form.html) +│ └── static/ # 靜態資源檔案 +│ ├── css/ +│ │ └── style.css # 全站共通樣式 +│ └── js/ +│ └── main.js # 客製化互動操作 +└── docs/ # 文件管理 + ├── PRD.md # 產品需求文件 + └── ARCHITECTURE.md # 系統架構文件 (本檔案) +``` + +--- + +## 3. 元件關係圖 + +以下展示瀏覽器發出請求後,系統內部各個元件如何運作互動: + +```mermaid +sequenceDiagram + participant Browser as 瀏覽器 (使用者) + participant Route as Flask Route (Controller) + participant Model as Model (資料與商業邏輯) + participant DB as SQLite DB + participant Template as Jinja2 Template (View) + + Browser->>Route: 1. 發送請求 (如: 點擊搜索、儲存食譜) + Route->>Model: 2. 呼叫模型層處理邏輯 + Model->>DB: 3. 執行 SQL 存取資料 + DB-->>Model: 4. 回傳實體資料 + Model-->>Route: 5. 整理為 Python Dict/Objects 物件 + Route->>Template: 6. 傳遞給 Jinja2 (呼叫 render_template) + Template-->>Route: 7. 渲染為靜態 HTML + Route-->>Browser: 8. 回傳 HTTP Response (HTML) +``` + +--- + +## 4. 關鍵設計決策 + +1. **不採用前後端分離架構** + - **原因**:為了能快速產出 MVP(Minimum Viable Product)進行驗證,降低專案的複雜度。使用 Flask 與 Jinja2 共構,能節省掉建置前後端通訊 API、跨域(CORS)問題以及撰寫 API 文件的成本。 +2. **採用關聯式資料庫設計** + - **原因**:系統的核心需求包含「從食材組合搜尋食譜」。這具備了明顯的「多對多」(Many-to-Many)關聯特性(一個食譜有多種食材,一種食材也能在多個食譜內),關聯式資料庫的 JOIN 查詢效能可以輕鬆處理這種場景。 +3. **區段式藍圖路由開發 (Flask Blueprints)** + - **原因**:即便初期的功能只有五項,但為了避免所有邏輯長在 `app.py` 導致過於肥大,我們從第一天就導入 Flask 的 Blueprints 概念。將應用切分「會員系統」(`auth`) 與「主系統」(`recipe`),讓日後擴充新功能更容易。 +4. **模組化的模板繼承 (Template Inheritance)** + - **原因**:將導覽列、樣式檔等共同區塊抽取至 `base.html`,其餘各種頁面繼承後只要填入專屬的 Block 內容。這能極大化減少重複的 HTML 程式碼,如果要調整全站版型(Theme),也只需要修改唯一的基礎模板即可。 From fcaa6e252a40919a9fee802f4b9dd667f7385862 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 9 Apr 2026 08:56:27 +0800 Subject: [PATCH 03/12] docs: add user flowchart --- docs/FLOWCHART.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/FLOWCHART.md diff --git a/docs/FLOWCHART.md b/docs/FLOWCHART.md new file mode 100644 index 00000000..6d41fe21 --- /dev/null +++ b/docs/FLOWCHART.md @@ -0,0 +1,87 @@ +# 系統流程圖與使用者操作路徑 (Flowcharts) - 食譜收藏夾系統 + +以下文件根據現有的 [PRD.md](./PRD.md) 和 [ARCHITECTURE.md](./ARCHITECTURE.md) 設計,透過視覺化圖表釐清使用者的操作路徑與後端的資料流,並定義出各個功能的對應路由。 + +## 1. 使用者流程圖(User Flow) + +此流程圖呈現一般使用者從進入網站開始,可能採取的各項操作行為。包含了「身份驗證」、「瀏覽與搜尋」以及「食譜管理」等核心情境。 + +```mermaid +flowchart TD + A([使用者開啟網頁]) --> B[首頁 / 搜尋入口] + + %% 搜尋與瀏覽分支 + B --> C{選擇搜尋方式} + C -->|關鍵字搜尋| D[食譜列表頁] + C -->|食材組合過濾| E[對應食材食譜列表頁] + + D --> F[單一食譜詳情頁] + E --> F + + %% 會員與登入分支 + B --> G{會員狀態} + G -->|未登入| H[登入/註冊頁面] + H -->|成功| B + + G -->|已登入| I[會員中心 / 我的收藏] + I --> J{操作項目} + + %% 增刪改查分支 + J -->|新增| K[填寫新建食譜表單] + K -->|儲存| L([資料庫處理並重導覽]) + L --> F + + J -->|查看與管理個人食譜| D + D -->|若為自己擁有的食譜| M{編輯或刪除?} + M -->|編輯| N[填寫編輯食譜表單] + N -->|儲存| L + M -->|刪除| O([確認刪除重導向]) + O --> I +``` + +## 2. 系統序列圖(Sequence Diagram) + +此圖以「**使用者新增食譜**」這項操作為例,詳細描繪了整個系統後端(MVC 架構)的運作順序——從網頁送出請求到資料庫存取並回傳重新導向的流程。 + +```mermaid +sequenceDiagram + actor User as 使用者 + participant Browser as 瀏覽器 + participant Route as Flask Route (Controller) + participant Model as Model (資料庫互動層) + participant DB as SQLite DB + + User->>Browser: 填寫「新增食譜表單」並點擊送出 + Browser->>Route: 發送 POST /recipes 請求 (夾帶表單資料) + + Route->>Route: 1. 驗證使用者是否已登入 + Route->>Route: 2. 驗證表單輸入 (如:標題是否空白) + + Route->>Model: 呼叫 Recipe.create(data) + Model->>DB: 執行 INSERT INTO recipes ... + DB-->>Model: 回傳新記錄的建立狀態 (ID) + Model-->>Route: 回傳新建食譜的物件 + + Route-->>Browser: 回傳 HTTP 302 Redirect 重導向至 /recipes/{id} (詳情頁) + Browser->>User: 顯示已成功新增的食譜頁面 +``` + +## 3. 功能清單對照表 + +本清單列出未來將實作的功能,以及對應的 URL 路徑 (Routes) 和 HTTP 請求方法。由於原生 HTML 表單僅支援 GET 與 POST,故我們在更新/刪除資源時會透過 `POST` 方法加上特定後綴路徑來實作。 + +| 功能模塊 | 具體功能描述 | HTTP 方法 | URL 路徑 (Route) | 備註 | +| --- | --- | --- | --- | --- | +| **公開瀏覽** | 網站首頁 | GET | `/` | 顯示搜尋框與推薦食譜 | +| | 食譜列表與搜尋結果 | GET | `/recipes` | 若帶有 `?q=` 參數則為關鍵字搜尋 | +| | 食材組合過濾搜尋 | GET | `/recipes/search_by_ingredients` | 依據選擇的多樣食材進行過濾 | +| | 檢視單一食譜詳情 | GET | `/recipes/` | 查看公開的食譜 | +| **會員管理** | 註冊帳號頁面與處理 | GET / POST | `/register` | 包含頁面渲染(GET)與表單送出(POST) | +| | 登入頁面與處理 | GET / POST | `/login` | 驗證帳密並建立 Session | +| | 登出處理 | GET | `/logout` | 清除使用者 Session | +| **食譜管理
(需登入)** | 新增食譜頁面 | GET | `/recipes/new` | 提供空白輸入表單 | +| | 儲存新食譜資料 | POST | `/recipes` | 將表單接收並寫入資料庫 | +| | 編輯食譜頁面 | GET | `/recipes//edit` | 將既有資料填入表單讓使用者修改 | +| | 儲存修改的食譜 | POST | `/recipes//update` | 儲存使用者更新的資料 | +| | 刪除食譜 | POST | `/recipes//delete` | 驗證擁有者身分或管理員權限後刪除 | +| **後台管理** | 管理員儀表板 | GET | `/admin` | 僅允許管理員檢視全域食譜與用戶列表 | From bdad83db0242fc2948684e0f9ce14f6c6ea15be3 Mon Sep 17 00:00:00 2001 From: bingr Date: Thu, 9 Apr 2026 18:02:19 +0800 Subject: [PATCH 04/12] docs: add PRD --- ...45\215\234\347\263\273\347\265\261_PRD.md" | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 "\347\267\232\344\270\212\347\245\210\347\246\217\350\210\207\347\256\227\345\221\275\345\215\240\345\215\234\347\263\273\347\265\261_PRD.md" diff --git "a/\347\267\232\344\270\212\347\245\210\347\246\217\350\210\207\347\256\227\345\221\275\345\215\240\345\215\234\347\263\273\347\265\261_PRD.md" "b/\347\267\232\344\270\212\347\245\210\347\246\217\350\210\207\347\256\227\345\221\275\345\215\240\345\215\234\347\263\273\347\265\261_PRD.md" new file mode 100644 index 00000000..34c1343e --- /dev/null +++ "b/\347\267\232\344\270\212\347\245\210\347\246\217\350\210\207\347\256\227\345\221\275\345\215\240\345\215\234\347\263\273\347\265\261_PRD.md" @@ -0,0 +1,62 @@ +# 產品需求文件 (PRD):線上祈福與算命占卜系統 + +## 1. 產品概述 +「線上祈福與算命占卜系統」是一款專為現代人設計的數位化心靈寄託平台。本系統將傳統的算命、占卜、抽籤等儀式與現代網頁技術結合,打破時間與空間的限制,讓使用者能隨時隨地尋求心靈指引,並提供儲存紀錄與線上隨喜(捐香油錢)的功能,打造完整的線上參拜與占卜體驗。 + +## 2. 目標用戶 (Target Audience) +* **尋求心靈慰藉與指引者:** 在生活、事業、感情上面臨迷惘,需要尋找解答或方向的人。 +* **對命理占卜感興趣的年輕族群:** 喜歡嘗試塔羅牌、星座塔羅、傳統廟宇抽籤等多元占卜方式的使用者。 +* **生活忙碌的現代人:** 沒有時間親自到訪實體廟宇或命理館,但仍希望進行祈福或算命的上班族或學生。 +* **海外或遠距離信仰者:** 居住在海外或遠方,無法輕易前往特定信仰中心,希望透過線上方式參與祈福與隨喜的民眾。 + +## 3. 核心功能需求 (Core Features) + +### 3.1 會員註冊及登入 (User Authentication) +* **功能描述:** 提供使用者建立個人帳號,以利系統記錄專屬的占卜結果與祈福歷程。 +* **細部需求:** + * 支援一般信箱 (Email) 註冊/登入。 + * (進階)支援第三方社群登入 (如 Google, Facebook, LINE)。 + * 使用者個人資料管理與密碼修改。 + +### 3.2 線上算命、占卜、抽籤 (Divination & Fortune-telling) +* **功能描述:** 提供多樣化的線上命理服務,模擬真實的抽籤與占卜流程。 +* **細部需求:** + * **傳統廟宇抽籤:** 模擬擲筊(允杯、笑杯、陰杯)決定是否賜籤,並抽出對應的籤詩(如六十甲子籤)。 + * **塔羅牌占卜:** 線上洗牌、抽牌,支援單張或多張牌陣(包含正逆位)。 + * 過程需具備互動性(如動畫特效、翻牌效果)以增強使用者的沉浸感。 + +### 3.3 占卜詳解 (Detailed Explanations) +* **功能描述:** 針對使用者抽出的籤詩或占卜結果,提供詳細、白話的解說。 +* **細部需求:** + * 依據不同的問題類別(如:感情、事業、健康、財運)給予針對性的解答。 + * 籤詩需包含:原意、白話翻譯、吉凶等級。 + * 塔羅牌需包含:牌面意義、針對問題的綜合解析。 + * (進階)可整合 AI (如 ChatGPT API) 提供更具彈性、個人化且有溫度的客製化分析。 + +### 3.4 儲存算命結果 (History & Results Saving) +* **功能描述:** 讓會員能夠回顧過去的占卜與抽籤紀錄,持續追蹤自身的運勢或問題發展。 +* **細部需求:** + * 個人專屬的「歷史紀錄」頁面。 + * 紀錄需包含:占卜日期、問事問題(例如:下半年的工作運)、使用的占卜方式、抽到的結果、以及詳解。 + * 允許使用者自行刪除或隱藏紀錄。 + +### 3.5 線上隨喜 / 捐香油錢 (Online Donation System) +* **功能描述:** 提供使用者在線上進行祈福還願或隨喜捐獻金錢的功能,支持系統營運或實體合作廟宇。 +* **細部需求:** + * 串接第三方支付金流(如綠界科技、Line Pay、信用卡等)。 + * 讓使用者自由輸入捐獻金額(香油錢)。 + * 捐獻完成後,提供線上專屬的「感謝狀」或「線上點燈/上香」動畫回饋。 + * 在會員專區保留歷史捐款/香油錢明細。 + +## 4. 使用者流程 (User Flow) +1. **訪客進入首頁:** 瀏覽平台提供的各項占卜與祈福服務簡介。 +2. **註冊/登入:** 系統引導訪客成為會員,提醒登入後方可永久保存占卜結果。 +3. **選擇服務:** 使用者選擇心儀的模式,如「傳統抽籤」或「塔羅占卜」。 +4. **進行儀式:** 輸入心中所想的問題,經過互動過程取得結果(點擊求籤、抽牌)。 +5. **查看詳解:** 系統展示結果與詳細解析,並自動將此紀錄儲存至「專屬紀錄區」。 +6. **祈福隨喜:** 若使用者對結果滿意或希望求心安,可點擊「捐香油錢」按鈕,進入金流支付流程,完成隨喜。 + +## 5. 後續擴充建議 (Future Roadmap) +* **線上點燈/安太歲:** 擴充年度祈福服務(如點光明燈、安太歲)。 +* **每日推播:** 透過 Email 或 LINE 官方帳號推廣每日運勢或一句吉祥話。 +* **專家一對一:** 媒合真實命理師傅進行線上排盤、視訊解盤服務。 From 986934fe406dab502cb4ad0086d36a55e10266d9 Mon Sep 17 00:00:00 2001 From: bingr Date: Thu, 9 Apr 2026 18:08:03 +0800 Subject: [PATCH 05/12] docs: add system architecture --- docs/ARCHITECTURE.md | 137 ++++++++++++++++++++++--------------------- 1 file changed, 69 insertions(+), 68 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 79cc1940..54b70c6a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,93 +1,94 @@ -# 系統架構文件 (Architecture) - 食譜收藏夾系統 +# 系統架構設計文件 (ARCHITECTURE) - 食譜收藏夾系統 ## 1. 技術架構說明 -本專案採用傳統的伺服器端渲染(Server-Side Rendering, SSR)架構進行開發,由後端框架一併處理業務邏輯與畫面渲染(不採用前後端分離)。 +本系統基於 Python 的 **Flask** 框架進行開發,並採用伺服器端渲染 (Server-Side Rendering, SSR) 的方式,讓所有的頁面產製與核心邏輯皆在後端統一處理與產出。 +* **後端框架 (Flask)**:輕量、具有高擴展性,能快速搭建應用程式,非常適合開發中小型 Web 專案。 +* **視圖模板 (Jinja2)**:與 Flask 緊密整合的模板語言。負責把後端處理好的資料庫內容,動態結合 HTML 直接發送給瀏覽器。因此,不需要建立複雜的前後端分離 API,大幅降低開發初期的時間成本。 +* **資料庫 (SQLite)**:以檔案的輕量級格式來儲存關聯式資料,符合 MVP(最小可行性產品)階段的需求,本地端開發、備份皆非常簡單。 +* **前端設計 (Vanilla CSS + JS)**:不依賴如 React 或 Vue 的大型框架。透過手寫 CSS 處理介面外觀,並用基本的 JavaScript 強化前端操作體驗(如按鈕互動、表單基本檢查)。 -### **1.1 選用技術與原因** -- **後端框架:Python + Flask** - - **原因**:Flask 是一套輕量級且靈活的框架,非常適合快速開發 MVP(最小可行產品)與中小型專案。它能輕鬆處理 HTTP 請求及路由配置。 -- **模板引擎:Jinja2** - - **原因**:與 Flask 高度整合,能夠在伺服器端將資料庫查詢到的變數(如食譜清單)直接注入到 HTML 頁面中,這對於 SEO 非常有利且易於開發上手。 -- **資料庫:SQLite** - - **原因**:設定簡單,無需另外架設與維護資料庫伺服器,資料是以單一檔案(如 `database.db`)的形式存在這台主機上,非常輕量,適合開發初期與小規模資料量。 - -### **1.2 Flask MVC 模式說明** -雖然 Flask 本身沒有強制的目錄架構,但我們依循經典的 MVC(Model-View-Controller)模式概念來組織程式碼: -- **Model(模型)**:負責與 SQLite 資料庫溝通,處理資料的存取與商業邏輯(例如:寫入新食譜、查詢包含特定食材的食譜)。 -- **View(視圖)**:負責使用者介面(UI)的呈現。在此系統中對應為放在 `templates/` 資料夾裡的 Jinja2 HTML 檔案。 -- **Controller(控制器)**:在這裡對應為 Flask 的 **Routes(路由)**,負責接收使用者的請求、向 Model 要求資料處理,接著將資料丟給 View (Jinja2) 去渲染,最終組成完整的網頁回傳。 +### Flask MVC 對應模式 +本專案在實作切分上,遵循 MVC (Model-View-Controller) 架構精神: +* **Model (模型)**:負責定義資料的結構與欄位,以及與資料庫直接進行讀寫操作(包括:使用者資料表、食譜資料表、與多對多的食材關聯)。對應專案的 `models/` 目錄。 +* **View (視圖)**:負責呈現給使用者操作、閱讀的視覺介面。在此專案中對應到 `templates/` 目錄內的 `.html` 檔案 (透過 Jinja2 渲染)。 +* **Controller (控制器)**:負責接收外界網頁發送的 HTTP Request (如點擊搜尋、送出表單),再透過呼叫 Model 層來處理資料,接著傳遞回 View 介面。對應專案中各個 `routes/` 檔案內的 API 與路由 (@app.route)。 --- ## 2. 專案資料夾結構 -以下是本專案的目錄結構初步規劃,將模組化拆分邏輯與視圖: +為保持程式碼的條理分明與後續容易延伸,我們設計以下資料夾結構: ```text -web_app_development/ -├── app.py # 應用程式入口點,負責啟動 Flask 伺服器 -├── requirements.txt # Python 套件相依清單 (開發時產出) -├── instance/ # 不進入版控的特定環境檔案 -│ └── database.db # SQLite 資料庫儲存檔 -├── app/ # 核心專案內文 -│ ├── __init__.py # 建立 Flask App、註冊設定檔 -│ ├── models/ # 【Model】資料庫結構定義 -│ │ ├── __init__.py -│ │ ├── user.py # 用戶資料表模型 -│ │ └── recipe.py # 食譜、食材、關聯表模型 -│ ├── routes/ # 【Controller】路由處置 -│ │ ├── __init__.py -│ │ ├── auth.py # 註冊、登入與權限路由 -│ │ └── recipe.py # 食譜 CRUD 及查詢路由 -│ ├── templates/ # 【View】Jinja2 HTML 模板 -│ │ ├── base.html # 共用基本排版 (含 navbar, footer 等) -│ │ ├── index.html # 首頁 (搜尋與展示) -│ │ ├── auth/ # 用戶相關頁面 (login.html, register.html) -│ │ └── recipe/ # 食譜相關頁面 (list.html, detail.html, form.html) -│ └── static/ # 靜態資源檔案 -│ ├── css/ -│ │ └── style.css # 全站共通樣式 -│ └── js/ -│ └── main.js # 客製化互動操作 -└── docs/ # 文件管理 - ├── PRD.md # 產品需求文件 - └── ARCHITECTURE.md # 系統架構文件 (本檔案) +recipe_app/ +│ +├── app/ ← 存放應用程式主體邏輯的目錄 +│ ├── __init__.py ← 初始化 Flask 實體,並在此註冊各種設定與 Blueprints +│ ├── models/ ← 資料庫對應的 Model 內容存放區 +│ │ ├── user.py ← 使用者關聯資料表 +│ │ ├── recipe.py ← 食譜主體、食材清單、多對多關聯等結構 +│ │ └── database.py ← 給 SQLite3 或 SQLAlchemy 共用的連線與管理設定 +│ ├── routes/ ← Flask 各項業務與路由規則 (Controller) +│ │ ├── auth.py ← 會員註冊、登入登出功能模組 +│ │ ├── recipe.py ← 處理食譜新增、查詢與多條件食材搜尋等模組 +│ │ └── admin.py ← 管理員檢視和下架內容等權限模組 +│ ├── templates/ ← Jinja2 網頁模板放置區 (View) +│ │ ├── base.html ← 全站共用的佈局版型 (導覽列與頁尾) +│ │ ├── auth/ ← 登入、註冊表單等登錄畫面 +│ │ ├── recipe/ ← 食譜列表首頁、食譜單一內頁與新增表單 +│ │ └── admin/ ← 後台管理專用介面 +│ └── static/ ← 靜態資源區(瀏覽器直接抓取的檔案) +│ ├── css/ ← 純手寫 CSS 樣式 +│ ├── js/ ← 瀏覽器端用 JavaScript 檔案 +│ └── images/ ← 備用的圖片存放區 (含使用者上傳儲存的內容) +│ +├── instance/ ← 不納入 Git 版控,安全層級較高的運行資料擺放區 +│ └── database.db ← SQLite 實體資料庫檔案 +│ +├── config.py ← 程式全域環境變數及安全 Secret Key 設定檔 +├── run.py ← 整個專案程式啟動點 +└── requirements.txt ← 專案必須要用的 Python 依賴套件表 (Flask 等) ``` --- ## 3. 元件關係圖 -以下展示瀏覽器發出請求後,系統內部各個元件如何運作互動: +以下展示專案在執行時,使用者端至後端的處理互動順序: ```mermaid -sequenceDiagram - participant Browser as 瀏覽器 (使用者) - participant Route as Flask Route (Controller) - participant Model as Model (資料與商業邏輯) - participant DB as SQLite DB - participant Template as Jinja2 Template (View) - - Browser->>Route: 1. 發送請求 (如: 點擊搜索、儲存食譜) - Route->>Model: 2. 呼叫模型層處理邏輯 - Model->>DB: 3. 執行 SQL 存取資料 - DB-->>Model: 4. 回傳實體資料 - Model-->>Route: 5. 整理為 Python Dict/Objects 物件 - Route->>Template: 6. 傳遞給 Jinja2 (呼叫 render_template) - Template-->>Route: 7. 渲染為靜態 HTML - Route-->>Browser: 8. 回傳 HTTP Response (HTML) +flowchart TD + Browser[使用者的瀏覽器Browser] + + subgraph Flask Application [Flask 應用程式] + Router[Flask 路由 Route / Controller] + JinjaTemplate[Jinja2 模板 Template / View] + Model[Database 資料庫模型 Model] + end + + DB[(SQLite 資料庫)] + + Browser -- "1. 送出搜尋多個食材的 Request (GET / POST)" --> Router + Router -- "2. 進行業務判斷與呼叫模型" --> Model + Model -- "3. 實際對資料庫進行檢索 (SQL)" --> DB + DB -- "4. 回傳符合條件的食譜紀錄" --> Model + Model -- "5. 將內容封裝回傳" --> Router + Router -- "6. 結合結果資料叫用 HTML" --> JinjaTemplate + JinjaTemplate -- "7. 渲染生成完整的網頁" --> Browser ``` --- ## 4. 關鍵設計決策 -1. **不採用前後端分離架構** - - **原因**:為了能快速產出 MVP(Minimum Viable Product)進行驗證,降低專案的複雜度。使用 Flask 與 Jinja2 共構,能節省掉建置前後端通訊 API、跨域(CORS)問題以及撰寫 API 文件的成本。 -2. **採用關聯式資料庫設計** - - **原因**:系統的核心需求包含「從食材組合搜尋食譜」。這具備了明顯的「多對多」(Many-to-Many)關聯特性(一個食譜有多種食材,一種食材也能在多個食譜內),關聯式資料庫的 JOIN 查詢效能可以輕鬆處理這種場景。 -3. **區段式藍圖路由開發 (Flask Blueprints)** - - **原因**:即便初期的功能只有五項,但為了避免所有邏輯長在 `app.py` 導致過於肥大,我們從第一天就導入 Flask 的 Blueprints 概念。將應用切分「會員系統」(`auth`) 與「主系統」(`recipe`),讓日後擴充新功能更容易。 -4. **模組化的模板繼承 (Template Inheritance)** - - **原因**:將導覽列、樣式檔等共同區塊抽取至 `base.html`,其餘各種頁面繼承後只要填入專屬的 Block 內容。這能極大化減少重複的 HTML 程式碼,如果要調整全站版型(Theme),也只需要修改唯一的基礎模板即可。 +以下為為符合 PRD 需求而訂立的重要設計決策方向: + +1. **無前後端分離設計(降低 MVP 開發複雜度)** + * **原因**:在此階段 (MVP) 首重驗證商業邏輯與核心功能的實作可行性。採取 Jinja2 進行一體的 HTML 畫面伺服端渲染,團隊可以省去撰寫各種規格嚴謹之 RESTful API 與處理前端狀態庫之門檻,將專注力放在「會員」與「食材搜尋關聯」上。 +2. **安全性全面防堵(密碼防護與過濾不當輸出)** + * **原因**:用戶若需註冊個人帳號,不能明文存放密碼,為遵循資訊安全最佳實踐必須導入如 `bcrypt` 來進行不可逆的雜湊加鹽處理。此外,對於 PRD 中要求的表單內容輸入防護,採用 Jinja2 本身內建的強大 Escaping 排版與嚴謹的資料庫預句處理 (Prepared Statements 或 ORM),可直接免除大量的 SQL Injection 及 XSS 攻擊的威脅。 +3. **多對多食材關聯設計與結構最佳化** + * **原因**:核心功能需支援「透過多樣食材篩選食譜」。傳統的單一字串模糊查詢會造成極差的匹配效能。於是我們在資料庫設計端會採取 `食譜(Recipe)` 與 `食材(Ingredient)` 分開建立,中間運用 `Recipe_Ingredient_Map` 對應表的「多對多 (Many-to-Many)」關聯。即使需要擴增幾千筆食材組合查詢,也能順利被 SQLite 單單用優化過的語法執行出來。 +4. **採用 Blueprint 路由模組化功能** + * **原因**:因為專案系統裡,明顯區分出了一般使用者認證 (`auth`)、資料核心業務 (`recipe`) 與特殊權限操作 (`admin`) 三條截然不同的線頭。使用 Flask 提供的 `Blueprint` 功能來做架構上的切分,可以降低各路由之間的依賴程度,未來新增需求時不怕會導致整個控制器大亂。 From 4bc14209ae1fb71687ca74b1ab092a0db09749f9 Mon Sep 17 00:00:00 2001 From: bingr Date: Thu, 9 Apr 2026 18:10:42 +0800 Subject: [PATCH 06/12] docs: add flowchart documentation for application workflow --- docs/FLOWCHART.md | 133 ++++++++++++++++++++++------------------------ 1 file changed, 63 insertions(+), 70 deletions(-) diff --git a/docs/FLOWCHART.md b/docs/FLOWCHART.md index 6d41fe21..9afd00e5 100644 --- a/docs/FLOWCHART.md +++ b/docs/FLOWCHART.md @@ -1,87 +1,80 @@ -# 系統流程圖與使用者操作路徑 (Flowcharts) - 食譜收藏夾系統 +# 系統與使用者流程圖 (FLOWCHART) - 食譜收藏夾系統 -以下文件根據現有的 [PRD.md](./PRD.md) 和 [ARCHITECTURE.md](./ARCHITECTURE.md) 設計,透過視覺化圖表釐清使用者的操作路徑與後端的資料流,並定義出各個功能的對應路由。 +本文件依據 [PRD.md](./PRD.md) 和 [ARCHITECTURE.md](./ARCHITECTURE.md) 的規格,繪製「使用者流程圖」與「系統序列圖」,並整理出「功能清單對照表」,以視覺化方式呈現系統藍圖。 -## 1. 使用者流程圖(User Flow) +## 1. 使用者流程圖 (User Flow) -此流程圖呈現一般使用者從進入網站開始,可能採取的各項操作行為。包含了「身份驗證」、「瀏覽與搜尋」以及「食譜管理」等核心情境。 +此流程圖展示使用者進入系統後的各種操作路徑,包含瀏覽、搜尋、註冊登入及食譜管理等行為。 ```mermaid -flowchart TD - A([使用者開啟網頁]) --> B[首頁 / 搜尋入口] +flowchart LR + Start([使用者首頁 / 訪客]) --> VisitList[瀏覽公開食譜清單] + Start --> Search[使用搜尋功能] + Start --> Auth{是否擁有會員帳號?} - %% 搜尋與瀏覽分支 - B --> C{選擇搜尋方式} - C -->|關鍵字搜尋| D[食譜列表頁] - C -->|食材組合過濾| E[對應食材食譜列表頁] - - D --> F[單一食譜詳情頁] - E --> F - - %% 會員與登入分支 - B --> G{會員狀態} - G -->|未登入| H[登入/註冊頁面] - H -->|成功| B - - G -->|已登入| I[會員中心 / 我的收藏] - I --> J{操作項目} - - %% 增刪改查分支 - J -->|新增| K[填寫新建食譜表單] - K -->|儲存| L([資料庫處理並重導覽]) - L --> F - - J -->|查看與管理個人食譜| D - D -->|若為自己擁有的食譜| M{編輯或刪除?} - M -->|編輯| N[填寫編輯食譜表單] - N -->|儲存| L - M -->|刪除| O([確認刪除重導向]) - O --> I + Search --> KeywordSearch[輸入關鍵字尋找食譜] + Search --> IngredientSearch[輸入多樣食材組合找靈感] + + Auth -->|否| Register[進入註冊頁面] + Auth -->|是| Login[進入登入頁面] + + Register --> Login + Login --> MemberDash([會員專屬空間]) + + MemberDash --> ViewMyRecipes[檢視自己的食譜收藏] + MemberDash --> AddRecipe[新增食譜與食材] + MemberDash --> EditRecipe[修改/編輯現有食譜] + MemberDash --> DeleteRecipe[刪除不需保留的食譜] + MemberDash --> Logout([登出系統]) ``` -## 2. 系統序列圖(Sequence Diagram) +## 2. 系統序列圖 (Sequence Diagram) -此圖以「**使用者新增食譜**」這項操作為例,詳細描繪了整個系統後端(MVC 架構)的運作順序——從網頁送出請求到資料庫存取並回傳重新導向的流程。 +以下序列圖以「**使用者新增食譜**」這項核心功能為例,展示資料由前端傳遞至後端進行處理並儲存的完整步驟。 ```mermaid sequenceDiagram - actor User as 使用者 - participant Browser as 瀏覽器 - participant Route as Flask Route (Controller) - participant Model as Model (資料庫互動層) - participant DB as SQLite DB - - User->>Browser: 填寫「新增食譜表單」並點擊送出 - Browser->>Route: 發送 POST /recipes 請求 (夾帶表單資料) - - Route->>Route: 1. 驗證使用者是否已登入 - Route->>Route: 2. 驗證表單輸入 (如:標題是否空白) - - Route->>Model: 呼叫 Recipe.create(data) - Model->>DB: 執行 INSERT INTO recipes ... - DB-->>Model: 回傳新記錄的建立狀態 (ID) - Model-->>Route: 回傳新建食譜的物件 - - Route-->>Browser: 回傳 HTTP 302 Redirect 重導向至 /recipes/{id} (詳情頁) - Browser->>User: 顯示已成功新增的食譜頁面 + actor User as 使用者 + participant Browser as 瀏覽器 (HTML/JS) + participant Flask as Flask 路由 (Controller) + participant Model as Recipe 模型 (Model) + participant DB as SQLite 資料庫 + + User->>Browser: 填寫食譜名稱、步驟與多樣食材並點擊送出 + Browser->>Flask: 發送 POST 請求至 /recipe/new + Flask->>Flask: 驗證使用者是否已登入及輸入格式 + alt 驗證失敗 + Flask-->>Browser: 返回錯誤訊息提示 (Flash) + Browser-->>User: 畫面顯示「輸入有誤」 + else 驗證成功 + Flask->>Model: 解析表單,呼叫新增食譜函式並帶入參數 + Model->>DB: INSERT INTO recipes (建立食譜主表) + DB-->>Model: 回傳 recipe_id + Model->>DB: INSERT INTO ingredients (建立新食材) + Model->>DB: INSERT INTO recipe_ingredient_map (建立多對多關聯) + DB-->>Model: 儲存成功 + Model-->>Flask: 回傳建立成功狀態 + Flask-->>Browser: HTTP 302 重導向 (Redirect) 至該食譜單一內頁 + Browser-->>User: 畫面顯示新增好的食譜詳情 + end ``` ## 3. 功能清單對照表 -本清單列出未來將實作的功能,以及對應的 URL 路徑 (Routes) 和 HTTP 請求方法。由於原生 HTML 表單僅支援 GET 與 POST,故我們在更新/刪除資源時會透過 `POST` 方法加上特定後綴路徑來實作。 +此表格列出本專案核心功能與對應的 URL 設計、HTTP 方法。此設計遵守了 RESTful 精神與本專案的 Blueprint 拆分原則。 -| 功能模塊 | 具體功能描述 | HTTP 方法 | URL 路徑 (Route) | 備註 | -| --- | --- | --- | --- | --- | -| **公開瀏覽** | 網站首頁 | GET | `/` | 顯示搜尋框與推薦食譜 | -| | 食譜列表與搜尋結果 | GET | `/recipes` | 若帶有 `?q=` 參數則為關鍵字搜尋 | -| | 食材組合過濾搜尋 | GET | `/recipes/search_by_ingredients` | 依據選擇的多樣食材進行過濾 | -| | 檢視單一食譜詳情 | GET | `/recipes/` | 查看公開的食譜 | -| **會員管理** | 註冊帳號頁面與處理 | GET / POST | `/register` | 包含頁面渲染(GET)與表單送出(POST) | -| | 登入頁面與處理 | GET / POST | `/login` | 驗證帳密並建立 Session | -| | 登出處理 | GET | `/logout` | 清除使用者 Session | -| **食譜管理
(需登入)** | 新增食譜頁面 | GET | `/recipes/new` | 提供空白輸入表單 | -| | 儲存新食譜資料 | POST | `/recipes` | 將表單接收並寫入資料庫 | -| | 編輯食譜頁面 | GET | `/recipes//edit` | 將既有資料填入表單讓使用者修改 | -| | 儲存修改的食譜 | POST | `/recipes//update` | 儲存使用者更新的資料 | -| | 刪除食譜 | POST | `/recipes//delete` | 驗證擁有者身分或管理員權限後刪除 | -| **後台管理** | 管理員儀表板 | GET | `/admin` | 僅允許管理員檢視全域食譜與用戶列表 | +| 功能區塊 | 功能名稱 | URL 路徑 | HTTP 方法 | 備註說明 | +| :--- | :--- | :--- | :--- | :--- | +| **公開瀏覽** | 首頁 (最新食譜列表) | `/` | GET | 訪客不需登入即可觀看 | +| | 檢視食譜詳細內容 | `/recipe/` | GET | 顯示食譜做法與配料 | +| | 關鍵字搜尋食譜 | `/search` | GET | 帶入查詢參數 `?q=xxx` | +| | 食材組合搜尋推薦 | `/search/ingredients` | GET | 帶入查詢參數 `?items=蛋,番茄` | +| **會員認證** | 註冊新帳號 | `/auth/register` | GET, POST | GET 取表單,POST 動作 | +| | 會員登入 | `/auth/login` | GET, POST | | +| | 會員登出 | `/auth/logout` | GET | 登出並清除 Session | +| **食譜管理** | 檢視個人專屬食譜區 | `/recipe/my` | GET | 需登入,列出該會員所建食譜 | +| | 新增食譜 | `/recipe/new` | GET, POST | 需登入,填寫各種欄位 | +| | 編輯食譜 | `/recipe//edit` | GET, POST | 只能修改自己名下的食譜 | +| | 刪除食譜 | `/recipe//delete`| POST | 同上,透過獨立 POST 接口防誤刪 | +| **系統管理** | 後台資料全覽 | `/admin` | GET | 僅限特定身分存取 (Admin) | +| | 強制下架違規食譜 | `/admin/recipe//delete` | POST | 系統管理員專屬權限 | From bad5f5616bbf13c68b1c96e299ead2f50f228a94 Mon Sep 17 00:00:00 2001 From: Student Date: Thu, 16 Apr 2026 17:47:19 +0800 Subject: [PATCH 07/12] feat: add database schema and models --- app/models/database.py | 40 ++++++++++ app/models/recipe.py | 169 +++++++++++++++++++++++++++++++++++++++++ app/models/user.py | 52 +++++++++++++ database/schema.sql | 38 +++++++++ docs/DB_DESIGN.md | 86 +++++++++++++++++++++ docs/commit/SKILL.md | 0 6 files changed, 385 insertions(+) create mode 100644 app/models/database.py create mode 100644 app/models/recipe.py create mode 100644 app/models/user.py create mode 100644 database/schema.sql create mode 100644 docs/DB_DESIGN.md create mode 100644 docs/commit/SKILL.md diff --git a/app/models/database.py b/app/models/database.py new file mode 100644 index 00000000..d3fbb091 --- /dev/null +++ b/app/models/database.py @@ -0,0 +1,40 @@ +import sqlite3 +import os +from flask import g + +# 指定資料庫檔案路徑,擺放在 instance 目錄中 +DB_PATH = os.path.join(os.getcwd(), 'instance', 'database.db') + +def get_db(): + """取得當前 Request 的資料庫連線,並使用 fetchall 時可利用欄位名稱存取字典 (Row)""" + db = getattr(g, '_database', None) + if db is None: + db = g._database = sqlite3.connect(DB_PATH) + db.row_factory = sqlite3.Row + + # 啟動外鍵支持,避免 ON DELETE CASCADE 不發生作用 + db.execute("PRAGMA foreign_keys = ON") + return db + +def close_db(exception=None): + """關閉資料庫連線,會在 Request 結束時自動被 Flask 呼叫""" + db = getattr(g, '_database', None) + if db is not None: + db.close() + +def init_db(app): + """初始化資料庫工具,載入 schema 建立資料表 (通常藉由 CLI 工具觸發)""" + # 確保 instance 目錄存在 + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + + with app.app_context(): + db = get_db() + # 讀取 database 資料夾底下的 schema.sql 檔案 + schema_path = os.path.join(os.getcwd(), 'database', 'schema.sql') + with open(schema_path, mode='r', encoding='utf-8') as f: + db.cursor().executescript(f.read()) + db.commit() + +def init_app(app): + """與 app 初始化做掛載配置""" + app.teardown_appcontext(close_db) diff --git a/app/models/recipe.py b/app/models/recipe.py new file mode 100644 index 00000000..aca665d7 --- /dev/null +++ b/app/models/recipe.py @@ -0,0 +1,169 @@ +from .database import get_db + +class Recipe: + @staticmethod + def create(user_id, title, description, steps, is_public=True): + db = get_db() + cursor = db.cursor() + cursor.execute( + '''INSERT INTO recipes (user_id, title, description, steps, is_public) + VALUES (?, ?, ?, ?, ?)''', + (user_id, title, description, steps, int(is_public)) + ) + db.commit() + return cursor.lastrowid + + @staticmethod + def get_by_id(recipe_id): + db = get_db() + cursor = db.cursor() + cursor.execute(''' + SELECT r.*, u.username as author_name + FROM recipes r + LEFT JOIN users u ON r.user_id = u.id + WHERE r.id = ? + ''', (recipe_id,)) + return cursor.fetchone() + + @staticmethod + def get_all(public_only=True): + db = get_db() + cursor = db.cursor() + if public_only: + cursor.execute(''' + SELECT r.*, u.username as author_name + FROM recipes r + LEFT JOIN users u ON r.user_id = u.id + WHERE r.is_public = 1 + ORDER BY r.created_at DESC + ''') + else: + cursor.execute(''' + SELECT r.*, u.username as author_name + FROM recipes r + LEFT JOIN users u ON r.user_id = u.id + ORDER BY r.created_at DESC + ''') + return cursor.fetchall() + + @staticmethod + def get_by_user_id(user_id): + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT * FROM recipes WHERE user_id = ? ORDER BY created_at DESC', (user_id,)) + return cursor.fetchall() + + @staticmethod + def update(recipe_id, title, description, steps, is_public): + db = get_db() + cursor = db.cursor() + cursor.execute( + '''UPDATE recipes + SET title = ?, description = ?, steps = ?, is_public = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?''', + (title, description, steps, int(is_public), recipe_id) + ) + db.commit() + + @staticmethod + def delete(recipe_id): + db = get_db() + cursor = db.cursor() + cursor.execute('DELETE FROM recipes WHERE id = ?', (recipe_id,)) + db.commit() + + @staticmethod + def search_by_keyword(keyword, public_only=True): + db = get_db() + cursor = db.cursor() + query = ''' + SELECT r.*, u.username as author_name + FROM recipes r + LEFT JOIN users u ON r.user_id = u.id + WHERE (r.title LIKE ? OR r.description LIKE ?) + ''' + if public_only: + query += ' AND r.is_public = 1' + query += ' ORDER BY r.created_at DESC' + + like_keyword = f'%{keyword}%' + cursor.execute(query, (like_keyword, like_keyword)) + return cursor.fetchall() + +class Ingredient: + @staticmethod + def get_or_create(name): + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT id FROM ingredients WHERE name = ?', (name,)) + row = cursor.fetchone() + if row: + return row['id'] + cursor.execute('INSERT INTO ingredients (name) VALUES (?)', (name,)) + db.commit() + return cursor.lastrowid + + @staticmethod + def get_by_recipe(recipe_id): + db = get_db() + cursor = db.cursor() + cursor.execute(''' + SELECT i.id, i.name FROM ingredients i + JOIN recipe_ingredients ri ON i.id = ri.ingredient_id + WHERE ri.recipe_id = ? + ''', (recipe_id,)) + return cursor.fetchall() + +class RecipeIngredientMap: + @staticmethod + def add_ingredients_to_recipe(recipe_id, ingredient_names): + db = get_db() + cursor = db.cursor() + + # 先清除現有該食譜的關聯 + cursor.execute('DELETE FROM recipe_ingredients WHERE recipe_id = ?', (recipe_id,)) + + for name in ingredient_names: + name = name.strip() + if not name: + continue + # 取回或建立食材的 ID + ingredient_id = Ingredient.get_or_create(name) + cursor.execute(''' + INSERT INTO recipe_ingredients (recipe_id, ingredient_id) + VALUES (?, ?) + ''', (recipe_id, ingredient_id)) + db.commit() + + @staticmethod + def search_recipes_by_ingredients(ingredient_names, public_only=True): + if not ingredient_names: + return [] + + db = get_db() + cursor = db.cursor() + + # 使用 IN 條件搜尋所有包含指定任意食材的食譜, + # 並使用 GROUP BY 結合 HAVING 確保食譜包含的「指定食材數量」等於搜尋數量。 + placeholders = ','.join(['?'] * len(ingredient_names)) + + query = f''' + SELECT r.*, u.username as author_name, COUNT(ri.ingredient_id) as match_count + FROM recipes r + JOIN recipe_ingredients ri ON r.id = ri.recipe_id + JOIN ingredients i ON ri.ingredient_id = i.id + LEFT JOIN users u ON r.user_id = u.id + WHERE i.name IN ({placeholders}) + ''' + if public_only: + query += ' AND r.is_public = 1' + + query += f''' + GROUP BY r.id + HAVING match_count = {len(ingredient_names)} + ORDER BY match_count DESC, r.created_at DESC + ''' + + # 執行搜尋 (這裡確保是同時包含所有搜尋指定的食材) + cursor.execute(query, tuple(ingredient_names)) + return cursor.fetchall() diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 00000000..3b96304f --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,52 @@ +from .database import get_db + +class User: + @staticmethod + def create(username, email, password_hash, role='user'): + db = get_db() + cursor = db.cursor() + cursor.execute( + '''INSERT INTO users (username, email, password_hash, role) + VALUES (?, ?, ?, ?)''', + (username, email, password_hash, role) + ) + db.commit() + return cursor.lastrowid + + @staticmethod + def get_by_id(user_id): + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,)) + return cursor.fetchone() + + @staticmethod + def get_by_email(email): + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT * FROM users WHERE email = ?', (email,)) + return cursor.fetchone() + + @staticmethod + def get_all(): + db = get_db() + cursor = db.cursor() + cursor.execute('SELECT * FROM users ORDER BY created_at DESC') + return cursor.fetchall() + + @staticmethod + def update(user_id, username, email): + db = get_db() + cursor = db.cursor() + cursor.execute( + 'UPDATE users SET username = ?, email = ? WHERE id = ?', + (username, email, user_id) + ) + db.commit() + + @staticmethod + def delete(user_id): + db = get_db() + cursor = db.cursor() + cursor.execute('DELETE FROM users WHERE id = ?', (user_id,)) + db.commit() diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 00000000..439d493b --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,38 @@ +-- 使用者資料表 +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT DEFAULT 'user', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 食譜資料表 +CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + description TEXT, + steps TEXT NOT NULL, + is_public BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- 食材資料表 (系統字典庫) +CREATE TABLE IF NOT EXISTS ingredients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- 食譜與食材 多對多關聯表 +CREATE TABLE IF NOT EXISTS recipe_ingredients ( + recipe_id INTEGER NOT NULL, + ingredient_id INTEGER NOT NULL, + PRIMARY KEY (recipe_id, ingredient_id), + FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, + FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE +); diff --git a/docs/DB_DESIGN.md b/docs/DB_DESIGN.md new file mode 100644 index 00000000..63e2e113 --- /dev/null +++ b/docs/DB_DESIGN.md @@ -0,0 +1,86 @@ +# 資料庫設計文件 (DB_DESIGN) - 食譜收藏夾系統 + +本文件由 `/db-design` skill 自動產生,根據 PRD 與系統架構文件定義出適合 MVP 版本的 SQLite 關聯式資料庫設計。 + +## 1. ER 圖(實體關係圖) + +```mermaid +erDiagram + USERS { + int id PK + string username + string email + string password_hash + string role + datetime created_at + } + + RECIPES { + int id PK + int user_id FK + string title + text description + text steps + boolean is_public + datetime created_at + datetime updated_at + } + + INGREDIENTS { + int id PK + string name + datetime created_at + } + + RECIPE_INGREDIENTS { + int recipe_id PK, FK + int ingredient_id PK, FK + } + + USERS ||--o{ RECIPES : "建立" + RECIPES ||--o{ RECIPE_INGREDIENTS : "包含" + INGREDIENTS ||--o{ RECIPE_INGREDIENTS : "被用於" +``` + +## 2. 資料表詳細說明 + +### 2.1 USERS (使用者表) +儲存使用者的基本帳號與登入資訊。 +- `id` (INTEGER): Primary Key,唯一識別碼,自動遞增。 +- `username` (TEXT): 必填,使用者自訂名稱。 +- `email` (TEXT): 必填,登入用信箱,必須唯一 (UNIQUE)。 +- `password_hash` (TEXT): 必填,加密後的密碼安全字串。 +- `role` (TEXT): `user` 或 `admin`,預設為 `user`。 +- `created_at` (DATETIME): 帳號建立時間,預設目前時間 CURRENT_TIMESTAMP。 + +### 2.2 RECIPES (食譜表) +記錄食譜的核心內容。 +- `id` (INTEGER): Primary Key,唯一識別碼。 +- `user_id` (INTEGER): Foreign Key,必填,關聯至 `users.id`,建立此食譜的使用者 ID。 +- `title` (TEXT): 必填,食譜名稱。 +- `description` (TEXT): 食譜簡介。 +- `steps` (TEXT): 必填,製作步驟,可儲存換行文字。 +- `is_public` (BOOLEAN): 是否對外公開,1: 公開 / 0: 私有,預設為 1。 +- `created_at` (DATETIME): 建立時間,預設 CURRENT_TIMESTAMP。 +- `updated_at` (DATETIME): 最後更新時間,預設 CURRENT_TIMESTAMP。 + +### 2.3 INGREDIENTS (食材表) +全系統共用的食材字典庫。 +- `id` (INTEGER): Primary Key,唯一識別碼。 +- `name` (TEXT): 必填,必須唯一 (UNIQUE),食材名稱(例如:蛋、番茄)。 +- `created_at` (DATETIME): 建立時間,預設 CURRENT_TIMESTAMP。 + +### 2.4 RECIPE_INGREDIENTS (食譜食材關聯表,多對多) +處理食譜與食材的「多對多」對應關係。 +- `recipe_id` (INTEGER): Foreign Key,關聯至 `recipes.id`。 +- `ingredient_id` (INTEGER): Foreign Key,關聯至 `ingredients.id`。 +- PK 為 `(recipe_id, ingredient_id)` 的組合鍵。 + +## 3. SQL 建表語法 +完整的建表語法請參考專案中的 `database/schema.sql`,系統將據以建立 SQLite 檔案庫。 + +## 4. Python Model 實作 +依照 MVC 架構,實作檔案儲存於 `app/models/`,使用 `sqlite3` 提供CRUD方法: +- `database.py`: 建立共用資料庫連線及初始化工具。 +- `user.py`: 操作 `users` 表的 User 物件。 +- `recipe.py`: 操作 `recipes`、`ingredients` 與多對多關聯的 Recipe / Ingredient 物件。 diff --git a/docs/commit/SKILL.md b/docs/commit/SKILL.md new file mode 100644 index 00000000..e69de29b From b59a0aefa7c1f29e360addc4c1f9fd3d79f10a7f Mon Sep 17 00:00:00 2001 From: Student Date: Thu, 16 Apr 2026 17:51:14 +0800 Subject: [PATCH 08/12] feat: add route skeleton and template plan --- app/routes/__init__.py | 11 ++++++ app/routes/admin.py | 18 +++++++++ app/routes/auth.py | 26 +++++++++++++ app/routes/recipe.py | 64 +++++++++++++++++++++++++++++++ docs/ROUTES.md | 87 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+) create mode 100644 app/routes/__init__.py create mode 100644 app/routes/admin.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/recipe.py create mode 100644 docs/ROUTES.md diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 00000000..638b77f7 --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1,11 @@ +from flask import Blueprint +from .auth import auth_bp +from .recipe import recipe_bp +from .admin import admin_bp + +def register_blueprints(app): + """將各領域的 Controller Router 註冊至主程式中""" + app.register_blueprint(auth_bp, url_prefix='/auth') + app.register_blueprint(admin_bp, url_prefix='/admin') + # Recipe 包含首頁與主要邏輯,不用前綴 + app.register_blueprint(recipe_bp) diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 00000000..aada7f5e --- /dev/null +++ b/app/routes/admin.py @@ -0,0 +1,18 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash + +admin_bp = Blueprint('admin', __name__) + +@admin_bp.route('/', methods=['GET']) +def dashboard(): + """ + GET: 系統後台總覽頁面。檢查當前 Session 使用者角色是否為 `admin`,並呈現全站相關資訊統計至 dashboard.html。 + """ + pass + +@admin_bp.route('/recipe//delete', methods=['POST']) +def admin_delete_recipe(id): + """ + POST: 強制刪除機制。僅有系統管理員能夠發動,將違反規範的內容下架。 + 重導向回管理員儀表板 (Dashboard)。 + """ + pass diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 00000000..0d811512 --- /dev/null +++ b/app/routes/auth.py @@ -0,0 +1,26 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, session + +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/register', methods=['GET', 'POST']) +def register(): + """ + GET: 顯示註冊表單 (templates/auth/register.html)。 + POST: 接收表單資料,驗證輸入是否合法、信箱是否重複,寫入資料庫並重新導向至登入頁面。 + """ + pass + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + """ + GET: 顯示登入表單 (templates/auth/login.html)。 + POST: 接收表單並比對資料庫密碼,驗證成功後將使用者狀態存入 Session,將用戶導回首頁。 + """ + pass + +@auth_bp.route('/logout', methods=['GET']) +def logout(): + """ + GET: 清除目前的 Session 登入狀態,重導向至首頁。 + """ + pass diff --git a/app/routes/recipe.py b/app/routes/recipe.py new file mode 100644 index 00000000..52f1c8c6 --- /dev/null +++ b/app/routes/recipe.py @@ -0,0 +1,64 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash + +recipe_bp = Blueprint('recipe', __name__) + +@recipe_bp.route('/', methods=['GET']) +def index(): + """ + GET: 首頁。呼叫 Model 取得所有公開的食譜,並呈現於 index.html。 + """ + pass + +@recipe_bp.route('/search', methods=['GET']) +def search(): + """ + GET: 關鍵字搜尋功能。藉由 `?q=xxx` 獲取參數,回傳匹配的食譜至 search_results.html。 + """ + pass + +@recipe_bp.route('/search/ingredients', methods=['GET']) +def ingredient_search(): + """ + GET: 食材組合搜尋功能。接收 `?items=蛋,番茄`,切分後至 DB 過濾出完全包含該些食材的食譜,呈現於 ingredient_search.html。 + """ + pass + +@recipe_bp.route('/recipe/', methods=['GET']) +def detail(id): + """ + GET: 單一食譜詳情頁面。根據食譜 ID 取得細節、步驟與配料,呈現於 detail.html,若找不到回傳 404。 + """ + pass + +@recipe_bp.route('/recipe/my', methods=['GET']) +def my_recipes(): + """ + GET: 專門列出登入用戶自己建立的食譜列表以供管理。需要驗證登入權限。呈現於 my_recipes.html。 + """ + pass + +@recipe_bp.route('/recipe/new', methods=['GET', 'POST']) +def new_recipe(): + """ + GET: 顯示新增食譜的表單 (new.html)。 + POST: 接收食譜資料與食材清單,寫入資料庫並建立多對多關聯。 + 需要驗證登入權限。 + """ + pass + +@recipe_bp.route('/recipe//edit', methods=['GET', 'POST']) +def edit_recipe(id): + """ + GET: 顯示編輯食譜表單 (edit.html),並預留當前食譜內容變數。 + POST: 接收編輯後的新資料與食材異動並儲存。 + 需要驗證登入權限,並且限制僅有食譜建立者 (user_id) 可見與修改。失敗拋錯 403 Forbidden。 + """ + pass + +@recipe_bp.route('/recipe//delete', methods=['POST']) +def delete_recipe(id): + """ + POST: 刪除指定的食譜。需再三確保為本人操作。刪除後預設連鎖會移除多對多食材關聯表內對應資料。 + 重導向至我的食譜列表首頁。 + """ + pass diff --git a/docs/ROUTES.md b/docs/ROUTES.md new file mode 100644 index 00000000..e25da896 --- /dev/null +++ b/docs/ROUTES.md @@ -0,0 +1,87 @@ +# 路由與頁面設計文件 (ROUTES) - 食譜收藏夾系統 + +本文件由 `/api-design` skill 自動產生,彙整了根據 FLOWCHART 與 DB_DESIGN 設計的所有 URL 路由對照與介面模板規劃。 + +## 1. 路由總覽表格 + +| 功能區塊 | HTTP 方法 | URL 路徑 | 對應模板 | 說明 | +| :--- | :--- | :--- | :--- | :--- | +| **首頁與瀏覽** | GET | `/` | `templates/recipe/index.html` | 顯示所有公開食譜(首頁)。 | +| **關鍵字搜尋** | GET | `/search` | `templates/recipe/search_results.html` | 顯示依食譜標題/簡介搜尋結果。 | +| **食材過濾搜尋**| GET | `/search/ingredients` | `templates/recipe/ingredient_search.html` | 顯示擁有指定多樣食材組合的食譜。 | +| **食譜詳情** | GET | `/recipe/` | `templates/recipe/detail.html` | 顯示單一食譜內容與製作步驟。 | +| **註冊頁面** | GET | `/auth/register` | `templates/auth/register.html` | 顯示註冊表單。 | +| **送出註冊** | POST | `/auth/register` | — | 接收表單並建立 User,成功後重導向登入。 | +| **登入頁面** | GET | `/auth/login` | `templates/auth/login.html` | 顯示登入表單。 | +| **送出登入** | POST | `/auth/login` | — | 校驗資料並建立 session,成功後重導向首頁。 | +| **會員登出** | GET | `/auth/logout` | — | 清除 session,重導向首頁。 | +| **我的食譜** | GET | `/recipe/my` | `templates/recipe/my_recipes.html` | 列出該登入會員建立的所有食譜。 | +| **新增食譜頁面**| GET | `/recipe/new` | `templates/recipe/new.html` | 顯示建立食譜與食材輸入表單。 | +| **送出新增食譜**| POST | `/recipe/new` | — | 儲存至 DB 並建立多對多關聯,重導向至詳情頁。| +| **編輯食譜頁面**| GET | `/recipe//edit`| `templates/recipe/edit.html` | 取出原始庫存資料,顯示編輯表單。 | +| **送出編輯食譜**| POST | `/recipe//edit`| — | 更新食譜資料與食材清單,重導向至詳情頁。 | +| **刪除食譜** | POST | `/recipe//delete`| — | 刪除自己名單下的食譜,重導向我的食譜頁。 | +| **後台總覽** | GET | `/admin` | `templates/admin/dashboard.html` | 唯有 Admin 權限可進入,檢視全站資料。 | +| **管理員刪除** | POST | `/admin/recipe//delete`| — | 強制刪除違規資料,重導向至後台總覽。 | + +## 2. 路由詳細說明 + +### Auth (會員模組) +* **`GET, POST /auth/register`**: + * 輸入:表單欄位 (`username`, `email`, `password`, `confirm_password`)。 + * 邏輯:檢查欄位必填與密碼一致性。檢查信箱是否重複 (`User.get_by_email`)。實作密碼雜湊,調用 `User.create`。 + * 輸出/錯誤:發生錯誤 `flash` 並重繪 `register.html`;成功重導向 `/auth/login`。 +* **`GET, POST /auth/login`**: + * 輸入:表單欄位 (`email`, `password`)。 + * 邏輯:以信箱取得會員,並驗證 `password_hash`。通過則賦予 `session['user_id']`。 + * 輸出/錯誤:失敗 `flash` 錯誤,成功重導向 `/` 或指定的 `next` 網址。 +* **`GET /auth/logout`**: + * 邏輯:呼叫 `session.clear()` 移除登入狀態並重導向首頁。 + +### Recipe (食譜核心模組) +* **`GET /` (index)**: + * 邏輯:呼叫 `Recipe.get_all(public_only=True)` 取得全站公開食譜。渲染 `index.html`。 +* **`GET /search`**: + * 輸入:URL Query Parameter `?q=xxx`。 + * 邏輯:呼叫 `Recipe.search_by_keyword(q)` 執行關鍵字模糊查詢。 +* **`GET /search/ingredients`**: + * 輸入:URL Query Parameter `?items=蛋,番茄`。 + * 邏輯:字串切分為陣列,呼叫 `RecipeIngredientMap.search_recipes_by_ingredients(items)`。 +* **`GET, POST /recipe/new`**: + * 輸入:表單 (`title`, `description`, `steps`, `ingredients`, `is_public`)。 + * 邏輯:需驗證身份(`@login_required`)。建立食譜獲取 `recipe_id`,再結合 `ingredients` 更新多對多關聯 (`RecipeIngredientMap.add_ingredients_to_recipe`)。如果驗證失敗返回表單。 +* **`GET, POST /recipe//edit`**: + * 邏輯:需登入且校驗 `recipe.user_id == current_user.id`。POST 時同步變更食譜本體與關聯食材單。若非本人嘗試修改需回傳 403 Forbidden。 +* **`POST /recipe//delete`**: + * 邏輯:驗證為本人所有,呼叫 `Recipe.delete(id)`,重導向我的食譜頁面。 +* **`GET /recipe/my`**: + * 邏輯:需登入,調用 `Recipe.get_by_user_id(current_user.id)` 獲取個人清單。 + +### Admin (管理員模組) +* **`GET /admin`**: + * 邏輯:需檢驗當中使用者角色是否具備 `admin` 身份,抓取所需全站數量清單資料,並渲染 `dashboard.html`。 +* **`POST /admin/recipe//delete`**: + * 邏輯:系統管理員高權限強制下架違規食譜內容。 + +## 3. Jinja2 模板清單 + +所有的模板檔案存放於 `app/templates/`。 + +共用外觀: +- `base.html` (定義 Navbar, Flash 訊息區塊, 引入 Vanilla CSS 手寫樣式、Footer) + +各自繼承 `{% extends "base.html" %}` 的子模板: +1. **認證相關 (`auth/`)** + - `register.html`: 註冊介面 + - `login.html`: 登入介面 +2. **食譜檢視 (`recipe/`)** + - `index.html`: 首頁主瀑布流設計 + - `search_results.html`: 一般搜尋字面與結果列表 + - `ingredient_search.html`: 現有食材過濾檢索與結果 (核心功能) + - `detail.html`: 單一食譜詳細圖文與配料步驟說明 + - `my_recipes.html`: 表列展示個人擁有清單與快速編輯入口 +3. **食譜寫入 (`recipe/`)** + - `new.html`: 輸入表單與多項食材組合輸入器 + - `edit.html`: 沿用新建的版型配置,載入舊資料變數 +4. **管理員 (`admin/`)** + - `dashboard.html`: 全站資料總覽與管制操作清單 From 1d50dc7ef0ab6dccc19c3154203252dd00170841 Mon Sep 17 00:00:00 2001 From: bingr Date: Thu, 16 Apr 2026 19:27:56 +0800 Subject: [PATCH 09/12] chore: add commit skill --- .agents/skills/commit/SKILL.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .agents/skills/commit/SKILL.md diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md new file mode 100644 index 00000000..e69de29b From 0f5dcf4961a71e93cde0e69f64da4afc1e3bfa4f Mon Sep 17 00:00:00 2001 From: bingr Date: Thu, 16 Apr 2026 19:33:15 +0800 Subject: [PATCH 10/12] feat: add database schema and models --- app/models/database.py | 21 ++++++++ app/models/ingredient.py | 103 +++++++++++++++++++++++++++++++++++++++ app/models/recipe.py | 81 ++++++++++++++++++++++++++++++ app/models/user.py | 62 +++++++++++++++++++++++ database/schema.sql | 31 ++++++++++++ docs/DB_DESIGN.md | 77 +++++++++++++++++++++++++++++ 6 files changed, 375 insertions(+) create mode 100644 app/models/database.py create mode 100644 app/models/ingredient.py create mode 100644 app/models/recipe.py create mode 100644 app/models/user.py create mode 100644 database/schema.sql create mode 100644 docs/DB_DESIGN.md diff --git a/app/models/database.py b/app/models/database.py new file mode 100644 index 00000000..0ee56eb3 --- /dev/null +++ b/app/models/database.py @@ -0,0 +1,21 @@ +import sqlite3 +import os + +DB_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../../instance/database.db') + +def get_db_connection(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + # Enable foreign keys + conn.execute('PRAGMA foreign_keys = ON') + return conn + +def init_db(): + conn = get_db_connection() + schema_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../../database/schema.sql') + if os.path.exists(schema_path): + with open(schema_path, 'r', encoding='utf-8') as f: + conn.executescript(f.read()) + conn.commit() + conn.close() diff --git a/app/models/ingredient.py b/app/models/ingredient.py new file mode 100644 index 00000000..5fbbaaae --- /dev/null +++ b/app/models/ingredient.py @@ -0,0 +1,103 @@ +from .database import get_db_connection + +class Ingredient: + @staticmethod + def create(name): + conn = get_db_connection() + cursor = conn.cursor() + try: + cursor.execute('INSERT INTO ingredients (name) VALUES (?)', (name,)) + conn.commit() + ingredient_id = cursor.lastrowid + except conn.IntegrityError: + # Ingredient already exists + ingredient = cursor.execute('SELECT id FROM ingredients WHERE name = ?', (name,)).fetchone() + ingredient_id = ingredient['id'] + conn.close() + return ingredient_id + + @staticmethod + def get_by_id(ingredient_id): + conn = get_db_connection() + ingredient = conn.execute('SELECT * FROM ingredients WHERE id = ?', (ingredient_id,)).fetchone() + conn.close() + return dict(ingredient) if ingredient else None + + @staticmethod + def get_by_name(name): + conn = get_db_connection() + ingredient = conn.execute('SELECT * FROM ingredients WHERE name = ?', (name,)).fetchone() + conn.close() + return dict(ingredient) if ingredient else None + + @staticmethod + def get_all(): + conn = get_db_connection() + ingredients = conn.execute('SELECT * FROM ingredients ORDER BY name').fetchall() + conn.close() + return [dict(i) for i in ingredients] + + @staticmethod + def link_recipe_ingredient(recipe_id, ingredient_id): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO recipe_ingredient_map (recipe_id, ingredient_id) VALUES (?, ?)', + (recipe_id, ingredient_id) + ) + conn.commit() + map_id = cursor.lastrowid + conn.close() + return map_id + + @staticmethod + def clear_recipe_ingredients(recipe_id): + conn = get_db_connection() + conn.execute('DELETE FROM recipe_ingredient_map WHERE recipe_id = ?', (recipe_id,)) + conn.commit() + conn.close() + + @staticmethod + def get_ingredients_for_recipe(recipe_id): + conn = get_db_connection() + query = ''' + SELECT i.* FROM ingredients i + JOIN recipe_ingredient_map m ON i.id = m.ingredient_id + WHERE m.recipe_id = ? + ''' + ingredients = conn.execute(query, (recipe_id,)).fetchall() + conn.close() + return [dict(i) for i in ingredients] + + @staticmethod + def search_recipes_by_ingredients(ingredient_names, show_private=False, user_id=None): + if not ingredient_names: + return [] + + conn = get_db_connection() + + # Build the dynamic query for finding recipes that contain ALL the specified ingredients + placeholders = ', '.join(['?'] * len(ingredient_names)) + + query = f''' + SELECT r.* FROM recipes r + JOIN recipe_ingredient_map m ON r.id = m.recipe_id + JOIN ingredients i ON m.ingredient_id = i.id + WHERE i.name IN ({placeholders}) + ''' + + if not show_private: + query += ' AND r.is_public = 1' + elif user_id is not None: + query += ' AND (r.is_public = 1 OR r.user_id = ?)' + + query += ' GROUP BY r.id HAVING COUNT(DISTINCT i.id) >= ? ORDER BY r.created_at DESC' + + params = list(ingredient_names) + if user_id is not None and show_private: + params.append(user_id) + params.append(len(ingredient_names)) + + recipes = conn.execute(query, tuple(params)).fetchall() + conn.close() + return [dict(r) for r in recipes] diff --git a/app/models/recipe.py b/app/models/recipe.py new file mode 100644 index 00000000..3e3f2e0f --- /dev/null +++ b/app/models/recipe.py @@ -0,0 +1,81 @@ +from .database import get_db_connection + +class Recipe: + @staticmethod + def create(user_id, title, steps, is_public=0, cover_image=None): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO recipes (user_id, title, steps, is_public, cover_image) VALUES (?, ?, ?, ?, ?)', + (user_id, title, steps, is_public, cover_image) + ) + conn.commit() + recipe_id = cursor.lastrowid + conn.close() + return recipe_id + + @staticmethod + def get_by_id(recipe_id): + conn = get_db_connection() + recipe = conn.execute('SELECT * FROM recipes WHERE id = ?', (recipe_id,)).fetchone() + conn.close() + return dict(recipe) if recipe else None + + @staticmethod + def get_all_public(): + conn = get_db_connection() + recipes = conn.execute('SELECT * FROM recipes WHERE is_public = 1 ORDER BY created_at DESC').fetchall() + conn.close() + return [dict(r) for r in recipes] + + @staticmethod + def get_by_user_id(user_id): + conn = get_db_connection() + recipes = conn.execute('SELECT * FROM recipes WHERE user_id = ? ORDER BY created_at DESC', (user_id,)).fetchall() + conn.close() + return [dict(r) for r in recipes] + + @staticmethod + def search_by_keyword(keyword, show_private=False, user_id=None): + conn = get_db_connection() + query = 'SELECT * FROM recipes WHERE title LIKE ? OR steps LIKE ?' + params = [f'%{keyword}%', f'%{keyword}%'] + + if not show_private: + query += ' AND is_public = 1' + elif user_id is not None: + query += ' AND (is_public = 1 OR user_id = ?)' + params.append(user_id) + + query += ' ORDER BY created_at DESC' + recipes = conn.execute(query, tuple(params)).fetchall() + conn.close() + return [dict(r) for r in recipes] + + @staticmethod + def update(recipe_id, title=None, steps=None, is_public=None, cover_image=None): + conn = get_db_connection() + recipe = Recipe.get_by_id(recipe_id) + if not recipe: + return False + + new_title = title if title is not None else recipe['title'] + new_steps = steps if steps is not None else recipe['steps'] + new_is_public = is_public if is_public is not None else recipe['is_public'] + new_cover_image = cover_image if cover_image is not None else recipe['cover_image'] + + conn.execute( + 'UPDATE recipes SET title = ?, steps = ?, is_public = ?, cover_image = ? WHERE id = ?', + (new_title, new_steps, new_is_public, new_cover_image, recipe_id) + ) + conn.commit() + conn.close() + return True + + @staticmethod + def delete(recipe_id): + conn = get_db_connection() + conn.execute('DELETE FROM recipes WHERE id = ?', (recipe_id,)) + conn.commit() + conn.close() + return True diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 00000000..e291b9ca --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,62 @@ +from .database import get_db_connection + +class User: + @staticmethod + def create(username, password_hash, is_admin=0): + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', + (username, password_hash, is_admin) + ) + conn.commit() + user_id = cursor.lastrowid + conn.close() + return user_id + + @staticmethod + def get_by_id(user_id): + conn = get_db_connection() + user = conn.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone() + conn.close() + return dict(user) if user else None + + @staticmethod + def get_by_username(username): + conn = get_db_connection() + user = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone() + conn.close() + return dict(user) if user else None + + @staticmethod + def get_all(): + conn = get_db_connection() + users = conn.execute('SELECT * FROM users').fetchall() + conn.close() + return [dict(u) for u in users] + + @staticmethod + def update(user_id, password_hash=None, is_admin=None): + conn = get_db_connection() + user = User.get_by_id(user_id) + if not user: + return False + + new_password = password_hash if password_hash is not None else user['password_hash'] + new_is_admin = is_admin if is_admin is not None else user['is_admin'] + + conn.execute( + 'UPDATE users SET password_hash = ?, is_admin = ? WHERE id = ?', + (new_password, new_is_admin, user_id) + ) + conn.commit() + conn.close() + return True + + @staticmethod + def delete(user_id): + conn = get_db_connection() + conn.execute('DELETE FROM users WHERE id = ?', (user_id,)) + conn.commit() + conn.close() + return True diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 00000000..e810748d --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + steps TEXT NOT NULL, + is_public INTEGER DEFAULT 0, + cover_image TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS ingredients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS recipe_ingredient_map ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recipe_id INTEGER NOT NULL, + ingredient_id INTEGER NOT NULL, + FOREIGN KEY(recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, + FOREIGN KEY(ingredient_id) REFERENCES ingredients(id) ON DELETE CASCADE +); diff --git a/docs/DB_DESIGN.md b/docs/DB_DESIGN.md new file mode 100644 index 00000000..c7bd3715 --- /dev/null +++ b/docs/DB_DESIGN.md @@ -0,0 +1,77 @@ +# 資料庫設計文件 (DB_DESIGN) + +## 1. ER 圖 + +```mermaid +erDiagram + USER { + integer id PK + string username + string password_hash + boolean is_admin + datetime created_at + } + RECIPE { + integer id PK + integer user_id FK + string title + string steps + boolean is_public + string cover_image + datetime created_at + } + INGREDIENT { + integer id PK + string name + } + RECIPE_INGREDIENT_MAP { + integer id PK + integer recipe_id FK + integer ingredient_id FK + } + + USER ||--o{ RECIPE : "creates" + RECIPE ||--o{ RECIPE_INGREDIENT_MAP : "has" + INGREDIENT ||--o{ RECIPE_INGREDIENT_MAP : "is part of" +``` + +## 2. 資料表詳細說明 + +### 2.1 USER (使用者表) +紀錄註冊會員的資訊。 +- `id`: INTEGER, Primary Key, 自動遞增。 +- `username`: TEXT, 必填, 唯一, 使用者的登入帳號或信箱。 +- `password_hash`: TEXT, 必填, 儲存 bcrypt 加密後的密碼。 +- `is_admin`: INTEGER, 區分是否為管理員 (0: 否, 1: 是),預設為 0。 +- `created_at`: TEXT, 帳號建立時間 (ISO 8601 格式)。 + +### 2.2 RECIPE (食譜表) +紀錄食譜的主要資訊,關聯至建立該食譜的使用者。 +- `id`: INTEGER, Primary Key, 自動遞增。 +- `user_id`: INTEGER, Foreign Key (對應 USER.id),必填,表示建立者。 +- `title`: TEXT, 必填,食譜名稱。 +- `steps`: TEXT, 必填,料理步驟說明。 +- `is_public`: INTEGER, 是否公開 (0: 私密, 1: 公開),預設為 0。 +- `cover_image`: TEXT, 封面圖片的檔案路徑 (可為 Null)。 +- `created_at`: TEXT, 食譜建立時間 (ISO 8601 格式)。 + +### 2.3 INGREDIENT (食材表) +全域的食材清單,避免重複的食材名稱。 +- `id`: INTEGER, Primary Key, 自動遞增。 +- `name`: TEXT, 必填, 唯一,食材名稱 (例如: "番茄", "雞蛋")。 + +### 2.4 RECIPE_INGREDIENT_MAP (食譜食材關聯表) +處理食譜與食材的「多對多 (Many-to-Many)」關係。 +- `id`: INTEGER, Primary Key, 自動遞增。 +- `recipe_id`: INTEGER, Foreign Key (對應 RECIPE.id),必填。 +- `ingredient_id`: INTEGER, Foreign Key (對應 INGREDIENT.id),必填。 + +## 3. SQL 建表語法 +存放於 `database/schema.sql`,提供直接產生上述資料表的語法。 + +## 4. Python Model 程式碼 +存放於 `app/models/`,採用 `sqlite3` 原生套件實作。 +- `app/models/database.py`: 共用的資料庫連線獲取與初始化與法。 +- `app/models/user.py`: 處理使用者的 CRUD。 +- `app/models/recipe.py`: 處理食譜的 CRUD。 +- `app/models/ingredient.py`: 處理食材庫與食譜食材映射關係的 CRUD。 From 41741f8c4e384ede7385eca50342ec6e4b351ab0 Mon Sep 17 00:00:00 2001 From: bingr Date: Thu, 16 Apr 2026 20:12:40 +0800 Subject: [PATCH 11/12] feat: complete MVP implementation with complete models, auth, recipe routes and pages --- .env.example | 3 + .gitignore | 21 +++ app/__init__.py | 27 +++ app/models/database.py | 39 ++-- app/models/ingredient.py | 188 ++++++++++++-------- app/models/recipe.py | 165 +++++++++++------ app/models/user.py | 126 ++++++++----- app/routes/admin.py | 31 ++-- app/routes/auth.py | 70 +++++++- app/routes/recipe.py | 128 +++++++++---- app/static/css/.gitkeep | 0 app/static/images/.gitkeep | 0 app/static/js/.gitkeep | 0 app/templates/admin/.gitkeep | 0 app/templates/admin/dashboard.html | 32 ++++ app/templates/auth/.gitkeep | 0 app/templates/auth/login.html | 36 ++++ app/templates/auth/register.html | 41 +++++ app/templates/base.html | 186 +++++++++++++++++++ app/templates/recipe/.gitkeep | 0 app/templates/recipe/detail.html | 28 +++ app/templates/recipe/edit.html | 28 +++ app/templates/recipe/index.html | 21 +++ app/templates/recipe/ingredient_search.html | 26 +++ app/templates/recipe/my_recipes.html | 26 +++ app/templates/recipe/new.html | 25 +++ app/templates/recipe/search_results.html | 17 ++ instance/.gitkeep | 0 requirements.txt | 3 + run.py | 7 + test_app.py | 75 ++++++++ 31 files changed, 1110 insertions(+), 239 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/static/css/.gitkeep create mode 100644 app/static/images/.gitkeep create mode 100644 app/static/js/.gitkeep create mode 100644 app/templates/admin/.gitkeep create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/auth/.gitkeep create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/base.html create mode 100644 app/templates/recipe/.gitkeep create mode 100644 app/templates/recipe/detail.html create mode 100644 app/templates/recipe/edit.html create mode 100644 app/templates/recipe/index.html create mode 100644 app/templates/recipe/ingredient_search.html create mode 100644 app/templates/recipe/my_recipes.html create mode 100644 app/templates/recipe/new.html create mode 100644 app/templates/recipe/search_results.html create mode 100644 instance/.gitkeep create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 test_app.py diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b4eeff5e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +FLASK_APP=run.py +FLASK_DEBUG=1 +SECRET_KEY=your_secret_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6e6883fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Environments +.env +.venv +env/ +venv/ +ENV/ + +# Database +instance/database.db + +# VSCode +.vscode/ + +# Ignore test scripts +fix.py +generate_views.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 00000000..feddabda --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,27 @@ +from flask import Flask +from dotenv import load_dotenv +import os + +load_dotenv() + +def create_app(): + app = Flask(__name__) + app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev_default_secret') + + # 確保 instance 資料夾存在,以供 SQLite 使用 + os.makedirs(app.instance_path, exist_ok=True) + + # 註冊 Blueprints + from app.routes.auth import auth_bp + from app.routes.recipe import recipe_bp + from app.routes.admin import admin_bp + + app.register_blueprint(auth_bp, url_prefix='/auth') + app.register_blueprint(recipe_bp) + app.register_blueprint(admin_bp, url_prefix='/admin') + + return app + +def init_db(): + from app.models.database import init_db as db_init + db_init() diff --git a/app/models/database.py b/app/models/database.py index 0ee56eb3..b5467a51 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -4,18 +4,31 @@ DB_PATH = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../../instance/database.db') def get_db_connection(): - os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - # Enable foreign keys - conn.execute('PRAGMA foreign_keys = ON') - return conn + """ + 建立並回傳一個與 SQLite 資料庫的連線。 + 預設啟用 row_factory 為 sqlite3.Row,以利透過字典鍵值存取資料。 + """ + try: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute('PRAGMA foreign_keys = ON') + return conn + except Exception as e: + print(f"Database connection error: {e}") + raise def init_db(): - conn = get_db_connection() - schema_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../../database/schema.sql') - if os.path.exists(schema_path): - with open(schema_path, 'r', encoding='utf-8') as f: - conn.executescript(f.read()) - conn.commit() - conn.close() + """ + 初始化資料庫。讀取 schema.sql 並執行建表語句。 + """ + try: + conn = get_db_connection() + schema_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), '../../database/schema.sql') + if os.path.exists(schema_path): + with open(schema_path, 'r', encoding='utf-8') as f: + conn.executescript(f.read()) + conn.commit() + conn.close() + except Exception as e: + print(f"Database initialization error: {e}") diff --git a/app/models/ingredient.py b/app/models/ingredient.py index 5fbbaaae..34ddb8ba 100644 --- a/app/models/ingredient.py +++ b/app/models/ingredient.py @@ -3,101 +3,141 @@ class Ingredient: @staticmethod def create(name): - conn = get_db_connection() - cursor = conn.cursor() + """ + 新增單一食材,若已存在則取得它的 ID 回傳。 + """ try: - cursor.execute('INSERT INTO ingredients (name) VALUES (?)', (name,)) - conn.commit() - ingredient_id = cursor.lastrowid - except conn.IntegrityError: - # Ingredient already exists - ingredient = cursor.execute('SELECT id FROM ingredients WHERE name = ?', (name,)).fetchone() - ingredient_id = ingredient['id'] - conn.close() - return ingredient_id + conn = get_db_connection() + cursor = conn.cursor() + try: + cursor.execute('INSERT INTO ingredients (name) VALUES (?)', (name,)) + conn.commit() + ingredient_id = cursor.lastrowid + except conn.IntegrityError: + ingredient = cursor.execute('SELECT id FROM ingredients WHERE name = ?', (name,)).fetchone() + ingredient_id = ingredient['id'] + conn.close() + return ingredient_id + except Exception as e: + print(f"Error creating ingredient: {e}") + return None @staticmethod def get_by_id(ingredient_id): - conn = get_db_connection() - ingredient = conn.execute('SELECT * FROM ingredients WHERE id = ?', (ingredient_id,)).fetchone() - conn.close() - return dict(ingredient) if ingredient else None + """取得單筆食材""" + try: + conn = get_db_connection() + ingredient = conn.execute('SELECT * FROM ingredients WHERE id = ?', (ingredient_id,)).fetchone() + conn.close() + return dict(ingredient) if ingredient else None + except Exception as e: + print(f"Error getting ingredient: {e}") + return None @staticmethod def get_by_name(name): - conn = get_db_connection() - ingredient = conn.execute('SELECT * FROM ingredients WHERE name = ?', (name,)).fetchone() - conn.close() - return dict(ingredient) if ingredient else None + """用名稱取得食材""" + try: + conn = get_db_connection() + ingredient = conn.execute('SELECT * FROM ingredients WHERE name = ?', (name,)).fetchone() + conn.close() + return dict(ingredient) if ingredient else None + except Exception as e: + print(f"Error getting ingredient: {e}") + return None @staticmethod def get_all(): - conn = get_db_connection() - ingredients = conn.execute('SELECT * FROM ingredients ORDER BY name').fetchall() - conn.close() - return [dict(i) for i in ingredients] + """取得所有食材""" + try: + conn = get_db_connection() + ingredients = conn.execute('SELECT * FROM ingredients ORDER BY name').fetchall() + conn.close() + return [dict(i) for i in ingredients] + except Exception as e: + print(f"Error getting all ingredients: {e}") + return [] @staticmethod def link_recipe_ingredient(recipe_id, ingredient_id): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - 'INSERT INTO recipe_ingredient_map (recipe_id, ingredient_id) VALUES (?, ?)', - (recipe_id, ingredient_id) - ) - conn.commit() - map_id = cursor.lastrowid - conn.close() - return map_id - + """將食譜與食材綁定(建立多對多關係資料)""" + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO recipe_ingredient_map (recipe_id, ingredient_id) VALUES (?, ?)', + (recipe_id, ingredient_id) + ) + conn.commit() + map_id = cursor.lastrowid + conn.close() + return map_id + except Exception as e: + print(f"Error linking recipe and ingredient: {e}") + return None + @staticmethod def clear_recipe_ingredients(recipe_id): - conn = get_db_connection() - conn.execute('DELETE FROM recipe_ingredient_map WHERE recipe_id = ?', (recipe_id,)) - conn.commit() - conn.close() + """清空一份食譜下的所有食材關聯""" + try: + conn = get_db_connection() + conn.execute('DELETE FROM recipe_ingredient_map WHERE recipe_id = ?', (recipe_id,)) + conn.commit() + conn.close() + return True + except Exception as e: + print(f"Error clearing recipe ingredients: {e}") + return False @staticmethod def get_ingredients_for_recipe(recipe_id): - conn = get_db_connection() - query = ''' - SELECT i.* FROM ingredients i - JOIN recipe_ingredient_map m ON i.id = m.ingredient_id - WHERE m.recipe_id = ? - ''' - ingredients = conn.execute(query, (recipe_id,)).fetchall() - conn.close() - return [dict(i) for i in ingredients] + """傳入食譜 ID,取得它所包含的所有食材陣列""" + try: + conn = get_db_connection() + query = ''' + SELECT i.* FROM ingredients i + JOIN recipe_ingredient_map m ON i.id = m.ingredient_id + WHERE m.recipe_id = ? + ''' + ingredients = conn.execute(query, (recipe_id,)).fetchall() + conn.close() + return [dict(i) for i in ingredients] + except Exception as e: + print(f"Error getting ingredients for recipe: {e}") + return [] @staticmethod def search_recipes_by_ingredients(ingredient_names, show_private=False, user_id=None): + """根據傳入的多組食材陣列名稱,找出完全滿足擁有的食譜清單""" if not ingredient_names: return [] - conn = get_db_connection() - - # Build the dynamic query for finding recipes that contain ALL the specified ingredients - placeholders = ', '.join(['?'] * len(ingredient_names)) - - query = f''' - SELECT r.* FROM recipes r - JOIN recipe_ingredient_map m ON r.id = m.recipe_id - JOIN ingredients i ON m.ingredient_id = i.id - WHERE i.name IN ({placeholders}) - ''' - - if not show_private: - query += ' AND r.is_public = 1' - elif user_id is not None: - query += ' AND (r.is_public = 1 OR r.user_id = ?)' - - query += ' GROUP BY r.id HAVING COUNT(DISTINCT i.id) >= ? ORDER BY r.created_at DESC' - - params = list(ingredient_names) - if user_id is not None and show_private: - params.append(user_id) - params.append(len(ingredient_names)) - - recipes = conn.execute(query, tuple(params)).fetchall() - conn.close() - return [dict(r) for r in recipes] + try: + conn = get_db_connection() + placeholders = ', '.join(['?'] * len(ingredient_names)) + + query = f''' + SELECT r.* FROM recipes r + JOIN recipe_ingredient_map m ON r.id = m.recipe_id + JOIN ingredients i ON m.ingredient_id = i.id + WHERE i.name IN ({placeholders}) + ''' + + if not show_private: + query += ' AND r.is_public = 1' + elif user_id is not None: + query += ' AND (r.is_public = 1 OR r.user_id = ?)' + + query += ' GROUP BY r.id HAVING COUNT(DISTINCT i.id) >= ? ORDER BY r.created_at DESC' + + params = list(ingredient_names) + if user_id is not None and show_private: + params.append(user_id) + params.append(len(ingredient_names)) + + recipes = conn.execute(query, tuple(params)).fetchall() + conn.close() + return [dict(r) for r in recipes] + except Exception as e: + print(f"Error searching recipes by ingredients: {e}") + return [] diff --git a/app/models/recipe.py b/app/models/recipe.py index 3e3f2e0f..0e608afe 100644 --- a/app/models/recipe.py +++ b/app/models/recipe.py @@ -3,79 +3,128 @@ class Recipe: @staticmethod def create(user_id, title, steps, is_public=0, cover_image=None): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - 'INSERT INTO recipes (user_id, title, steps, is_public, cover_image) VALUES (?, ?, ?, ?, ?)', - (user_id, title, steps, is_public, cover_image) - ) - conn.commit() - recipe_id = cursor.lastrowid - conn.close() - return recipe_id + """ + 新增一筆食譜記錄。 + """ + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO recipes (user_id, title, steps, is_public, cover_image) VALUES (?, ?, ?, ?, ?)', + (user_id, title, steps, is_public, cover_image) + ) + conn.commit() + recipe_id = cursor.lastrowid + conn.close() + return recipe_id + except Exception as e: + print(f"Error creating recipe: {e}") + return None @staticmethod def get_by_id(recipe_id): - conn = get_db_connection() - recipe = conn.execute('SELECT * FROM recipes WHERE id = ?', (recipe_id,)).fetchone() - conn.close() - return dict(recipe) if recipe else None + """ + 取得單筆食譜。 + """ + try: + conn = get_db_connection() + recipe = conn.execute('SELECT * FROM recipes WHERE id = ?', (recipe_id,)).fetchone() + conn.close() + return dict(recipe) if recipe else None + except Exception as e: + print(f"Error getting recipe: {e}") + return None @staticmethod def get_all_public(): - conn = get_db_connection() - recipes = conn.execute('SELECT * FROM recipes WHERE is_public = 1 ORDER BY created_at DESC').fetchall() - conn.close() - return [dict(r) for r in recipes] + """ + 取得所有公開狀態的食譜。 + """ + try: + conn = get_db_connection() + recipes = conn.execute('SELECT * FROM recipes WHERE is_public = 1 ORDER BY created_at DESC').fetchall() + conn.close() + return [dict(r) for r in recipes] + except Exception as e: + print(f"Error getting public recipes: {e}") + return [] @staticmethod def get_by_user_id(user_id): - conn = get_db_connection() - recipes = conn.execute('SELECT * FROM recipes WHERE user_id = ? ORDER BY created_at DESC', (user_id,)).fetchall() - conn.close() - return [dict(r) for r in recipes] - + """ + 依創作者獲取食譜。 + """ + try: + conn = get_db_connection() + recipes = conn.execute('SELECT * FROM recipes WHERE user_id = ? ORDER BY created_at DESC', (user_id,)).fetchall() + conn.close() + return [dict(r) for r in recipes] + except Exception as e: + print(f"Error getting user recipes: {e}") + return [] + @staticmethod def search_by_keyword(keyword, show_private=False, user_id=None): - conn = get_db_connection() - query = 'SELECT * FROM recipes WHERE title LIKE ? OR steps LIKE ?' - params = [f'%{keyword}%', f'%{keyword}%'] - - if not show_private: - query += ' AND is_public = 1' - elif user_id is not None: - query += ' AND (is_public = 1 OR user_id = ?)' - params.append(user_id) - - query += ' ORDER BY created_at DESC' - recipes = conn.execute(query, tuple(params)).fetchall() - conn.close() - return [dict(r) for r in recipes] + """ + 關鍵字搜尋食譜。可透過權限參數決定是否能搜尋到私人食譜。 + """ + try: + conn = get_db_connection() + query = 'SELECT * FROM recipes WHERE title LIKE ? OR steps LIKE ?' + params = [f'%{keyword}%', f'%{keyword}%'] + + if not show_private: + query += ' AND is_public = 1' + elif user_id is not None: + query += ' AND (is_public = 1 OR user_id = ?)' + params.append(user_id) + + query += ' ORDER BY created_at DESC' + recipes = conn.execute(query, tuple(params)).fetchall() + conn.close() + return [dict(r) for r in recipes] + except Exception as e: + print(f"Error searching recipes: {e}") + return [] @staticmethod def update(recipe_id, title=None, steps=None, is_public=None, cover_image=None): - conn = get_db_connection() - recipe = Recipe.get_by_id(recipe_id) - if not recipe: - return False + """ + 更新食譜。 + """ + try: + conn = get_db_connection() + recipe = Recipe.get_by_id(recipe_id) + if not recipe: + return False + + new_title = title if title is not None else recipe['title'] + new_steps = steps if steps is not None else recipe['steps'] + new_is_public = is_public if is_public is not None else recipe['is_public'] + new_cover_image = cover_image if cover_image is not None else recipe['cover_image'] - new_title = title if title is not None else recipe['title'] - new_steps = steps if steps is not None else recipe['steps'] - new_is_public = is_public if is_public is not None else recipe['is_public'] - new_cover_image = cover_image if cover_image is not None else recipe['cover_image'] - - conn.execute( - 'UPDATE recipes SET title = ?, steps = ?, is_public = ?, cover_image = ? WHERE id = ?', - (new_title, new_steps, new_is_public, new_cover_image, recipe_id) - ) - conn.commit() - conn.close() - return True + conn.execute( + 'UPDATE recipes SET title = ?, steps = ?, is_public = ?, cover_image = ? WHERE id = ?', + (new_title, new_steps, new_is_public, new_cover_image, recipe_id) + ) + conn.commit() + conn.close() + return True + except Exception as e: + print(f"Error updating recipe: {e}") + return False @staticmethod def delete(recipe_id): - conn = get_db_connection() - conn.execute('DELETE FROM recipes WHERE id = ?', (recipe_id,)) - conn.commit() - conn.close() - return True + """ + 刪除食譜。 + """ + try: + conn = get_db_connection() + conn.execute('DELETE FROM recipes WHERE id = ?', (recipe_id,)) + conn.commit() + conn.close() + return True + except Exception as e: + print(f"Error deleting recipe: {e}") + return False diff --git a/app/models/user.py b/app/models/user.py index e291b9ca..828ee281 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -3,60 +3,104 @@ class User: @staticmethod def create(username, password_hash, is_admin=0): - conn = get_db_connection() - cursor = conn.cursor() - cursor.execute( - 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', - (username, password_hash, is_admin) - ) - conn.commit() - user_id = cursor.lastrowid - conn.close() - return user_id + """ + 新增一筆使用者記錄。 + 回傳新建使用者的 id,發生例外時回傳 None。 + """ + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute( + 'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)', + (username, password_hash, is_admin) + ) + conn.commit() + user_id = cursor.lastrowid + conn.close() + return user_id + except Exception as e: + print(f"Error creating user: {e}") + return None @staticmethod def get_by_id(user_id): - conn = get_db_connection() - user = conn.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone() - conn.close() - return dict(user) if user else None + """ + 根據 id 取得單筆使用者記錄。 + """ + try: + conn = get_db_connection() + user = conn.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone() + conn.close() + return dict(user) if user else None + except Exception as e: + print(f"Error getting user by id: {e}") + return None @staticmethod def get_by_username(username): - conn = get_db_connection() - user = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone() - conn.close() - return dict(user) if user else None + """ + 根據 username 取得單筆使用者記錄。 + """ + try: + conn = get_db_connection() + user = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone() + conn.close() + return dict(user) if user else None + except Exception as e: + print(f"Error getting user by username: {e}") + return None @staticmethod def get_all(): - conn = get_db_connection() - users = conn.execute('SELECT * FROM users').fetchall() - conn.close() - return [dict(u) for u in users] + """ + 取得所有使用者記錄。 + """ + try: + conn = get_db_connection() + users = conn.execute('SELECT * FROM users').fetchall() + conn.close() + return [dict(u) for u in users] + except Exception as e: + print(f"Error getting all users: {e}") + return [] @staticmethod def update(user_id, password_hash=None, is_admin=None): - conn = get_db_connection() - user = User.get_by_id(user_id) - if not user: - return False + """ + 更新指定的 user 記錄。 + 只更新傳入且非 None 的參數。 + """ + try: + conn = get_db_connection() + user = User.get_by_id(user_id) + if not user: + return False + + new_password = password_hash if password_hash is not None else user['password_hash'] + new_is_admin = is_admin if is_admin is not None else user['is_admin'] - new_password = password_hash if password_hash is not None else user['password_hash'] - new_is_admin = is_admin if is_admin is not None else user['is_admin'] - - conn.execute( - 'UPDATE users SET password_hash = ?, is_admin = ? WHERE id = ?', - (new_password, new_is_admin, user_id) - ) - conn.commit() - conn.close() - return True + conn.execute( + 'UPDATE users SET password_hash = ?, is_admin = ? WHERE id = ?', + (new_password, new_is_admin, user_id) + ) + conn.commit() + conn.close() + return True + except Exception as e: + print(f"Error updating user: {e}") + return False @staticmethod def delete(user_id): - conn = get_db_connection() - conn.execute('DELETE FROM users WHERE id = ?', (user_id,)) - conn.commit() - conn.close() - return True + """ + 刪除指定使用者記錄。 + """ + try: + conn = get_db_connection() + conn.execute('DELETE FROM users WHERE id = ?', (user_id,)) + conn.commit() + conn.close() + return True + except Exception as e: + print(f"Error deleting user: {e}") + return False diff --git a/app/routes/admin.py b/app/routes/admin.py index aada7f5e..f38b94c6 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,18 +1,25 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from app.models.user import User +from app.models.recipe import Recipe +from app.models.ingredient import Ingredient admin_bp = Blueprint('admin', __name__) @admin_bp.route('/', methods=['GET']) -def dashboard(): - """ - GET: 系統後台總覽頁面。檢查當前 Session 使用者角色是否為 `admin`,並呈現全站相關資訊統計至 dashboard.html。 - """ - pass +def index(): + if not session.get('user_id') or not session.get('is_admin'): + flash('您未具備管理員權限!', 'danger') + return redirect(url_for('recipe.index')) + users = User.get_all() + recipes = Recipe.search_by_keyword('', show_private=True, user_id=None) + return render_template('admin/dashboard.html', users=users, recipes=recipes) @admin_bp.route('/recipe//delete', methods=['POST']) -def admin_delete_recipe(id): - """ - POST: 強制刪除機制。僅有系統管理員能夠發動,將違反規範的內容下架。 - 重導向回管理員儀表板 (Dashboard)。 - """ - pass +def delete_recipe(id): + if not session.get('user_id') or not session.get('is_admin'): + flash('您未具備管理員權限!', 'danger') + return redirect(url_for('recipe.index')) + Ingredient.clear_recipe_ingredients(id) + Recipe.delete(id) + flash('已強制刪除違規資料。', 'success') + return redirect(url_for('admin.index')) \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index 0d811512..cdd5556e 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,4 +1,6 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, session +import bcrypt +from app.models.user import User auth_bp = Blueprint('auth', __name__) @@ -8,7 +10,44 @@ def register(): GET: 顯示註冊表單 (templates/auth/register.html)。 POST: 接收表單資料,驗證輸入是否合法、信箱是否重複,寫入資料庫並重新導向至登入頁面。 """ - pass + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + confirm_password = request.form.get('confirm_password') + + # 基礎輸入驗證 + if not username or not password or not confirm_password: + flash('請填寫所有必填欄位!', 'danger') + return redirect(url_for('auth.register')) + + if password != confirm_password: + flash('兩次輸入的密碼不一致!', 'danger') + return redirect(url_for('auth.register')) + + # 檢查帳號是否已被註冊 + existing_user = User.get_by_username(username) + if existing_user: + flash('該使用者帳號 (信箱) 已被註冊!', 'danger') + return redirect(url_for('auth.register')) + + # 密碼雜湊加密 (Bcrypt) + try: + password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + user_id = User.create(username=username, password_hash=password_hash, is_admin=0) + + if user_id: + flash('註冊成功!請登入。', 'success') + return redirect(url_for('auth.login')) + else: + flash('系統錯誤,註冊失敗,請稍後再試。', 'danger') + except Exception as e: + print(f"Bcrypt Error: {e}") + flash('伺服器發生例外錯誤。', 'danger') + + return redirect(url_for('auth.register')) + + return render_template('auth/register.html') + @auth_bp.route('/login', methods=['GET', 'POST']) def login(): @@ -16,11 +55,36 @@ def login(): GET: 顯示登入表單 (templates/auth/login.html)。 POST: 接收表單並比對資料庫密碼,驗證成功後將使用者狀態存入 Session,將用戶導回首頁。 """ - pass + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + + if not username or not password: + flash('請輸入帳號與密碼!', 'danger') + return redirect(url_for('auth.login')) + + user = User.get_by_username(username) + if user: + # 校驗雜湊密碼 + if bcrypt.checkpw(password.encode('utf-8'), user['password_hash'].encode('utf-8')): + # 設定 Session + session['user_id'] = user['id'] + session['username'] = user['username'] + session['is_admin'] = user['is_admin'] + flash('登入成功!', 'success') + return redirect(url_for('recipe.index')) + + flash('帳號或密碼錯誤!', 'danger') + return redirect(url_for('auth.login')) + + return render_template('auth/login.html') + @auth_bp.route('/logout', methods=['GET']) def logout(): """ GET: 清除目前的 Session 登入狀態,重導向至首頁。 """ - pass + session.clear() + flash('您已成功登出。', 'info') + return redirect(url_for('recipe.index')) diff --git a/app/routes/recipe.py b/app/routes/recipe.py index 52f1c8c6..c57921f6 100644 --- a/app/routes/recipe.py +++ b/app/routes/recipe.py @@ -1,64 +1,116 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from app.models.recipe import Recipe +from app.models.ingredient import Ingredient +from app.models.user import User recipe_bp = Blueprint('recipe', __name__) @recipe_bp.route('/', methods=['GET']) def index(): - """ - GET: 首頁。呼叫 Model 取得所有公開的食譜,並呈現於 index.html。 - """ - pass + recipes = Recipe.get_all_public() + return render_template('recipe/index.html', recipes=recipes) @recipe_bp.route('/search', methods=['GET']) def search(): - """ - GET: 關鍵字搜尋功能。藉由 `?q=xxx` 獲取參數,回傳匹配的食譜至 search_results.html。 - """ - pass + q = request.args.get('q', '') + recipes = Recipe.search_by_keyword(q) + return render_template('recipe/search_results.html', recipes=recipes, keyword=q) @recipe_bp.route('/search/ingredients', methods=['GET']) def ingredient_search(): - """ - GET: 食材組合搜尋功能。接收 `?items=蛋,番茄`,切分後至 DB 過濾出完全包含該些食材的食譜,呈現於 ingredient_search.html。 - """ - pass + items_str = request.args.get('items', '') + if items_str.strip(): + items = [i.strip() for i in items_str.split(',') if i.strip()] + recipes = Ingredient.search_recipes_by_ingredients(items) + else: + items = [] + recipes = [] + return render_template('recipe/ingredient_search.html', recipes=recipes, items=items) @recipe_bp.route('/recipe/', methods=['GET']) def detail(id): - """ - GET: 單一食譜詳情頁面。根據食譜 ID 取得細節、步驟與配料,呈現於 detail.html,若找不到回傳 404。 - """ - pass + recipe = Recipe.get_by_id(id) + if not recipe: + flash('食譜不存在', 'danger') + return redirect(url_for('recipe.index')) + if not recipe['is_public']: + if session.get('user_id') != recipe['user_id'] and not session.get('is_admin'): + flash('您沒有權限檢視此食譜', 'danger') + return redirect(url_for('recipe.index')) + ingredients = Ingredient.get_ingredients_for_recipe(id) + author = User.get_by_id(recipe['user_id']) + return render_template('recipe/detail.html', recipe=recipe, ingredients=ingredients, author=author) @recipe_bp.route('/recipe/my', methods=['GET']) def my_recipes(): - """ - GET: 專門列出登入用戶自己建立的食譜列表以供管理。需要驗證登入權限。呈現於 my_recipes.html。 - """ - pass + if not session.get('user_id'): + flash('請先登入', 'danger') + return redirect(url_for('auth.login')) + recipes = Recipe.get_by_user_id(session['user_id']) + return render_template('recipe/my_recipes.html', recipes=recipes) @recipe_bp.route('/recipe/new', methods=['GET', 'POST']) def new_recipe(): - """ - GET: 顯示新增食譜的表單 (new.html)。 - POST: 接收食譜資料與食材清單,寫入資料庫並建立多對多關聯。 - 需要驗證登入權限。 - """ - pass + if not session.get('user_id'): + flash('請先登入', 'danger') + return redirect(url_for('auth.login')) + if request.method == 'POST': + title = request.form.get('title') + steps = request.form.get('steps') + is_public = 1 if request.form.get('is_public') else 0 + ingredients_input = request.form.get('ingredients') + if not title or not steps: + flash('標題與步驟為必填', 'danger') + return redirect(url_for('recipe.new_recipe')) + recipe_id = Recipe.create(session['user_id'], title, steps, is_public, None) + if ingredients_input: + items = [i.strip() for i in ingredients_input.split(',')] + for item in items: + if item: + ing_id = Ingredient.create(item) + Ingredient.link_recipe_ingredient(recipe_id, ing_id) + flash('建立成功!', 'success') + return redirect(url_for('recipe.detail', id=recipe_id)) + return render_template('recipe/new.html') @recipe_bp.route('/recipe//edit', methods=['GET', 'POST']) def edit_recipe(id): - """ - GET: 顯示編輯食譜表單 (edit.html),並預留當前食譜內容變數。 - POST: 接收編輯後的新資料與食材異動並儲存。 - 需要驗證登入權限,並且限制僅有食譜建立者 (user_id) 可見與修改。失敗拋錯 403 Forbidden。 - """ - pass + if not session.get('user_id'): + flash('請先登入', 'danger') + return redirect(url_for('auth.login')) + recipe = Recipe.get_by_id(id) + if not recipe or recipe['user_id'] != session['user_id']: + flash('權限不足', 'danger') + return redirect(url_for('recipe.index')) + if request.method == 'POST': + title = request.form.get('title') + steps = request.form.get('steps') + is_public = 1 if request.form.get('is_public') else 0 + ingredients_input = request.form.get('ingredients') + Recipe.update(id, title=title, steps=steps, is_public=is_public) + Ingredient.clear_recipe_ingredients(id) + if ingredients_input: + items = [i.strip() for i in ingredients_input.split(',')] + for item in items: + if item: + ing_id = Ingredient.create(item) + Ingredient.link_recipe_ingredient(id, ing_id) + flash('更新成功!', 'success') + return redirect(url_for('recipe.detail', id=id)) + current_ingredients = Ingredient.get_ingredients_for_recipe(id) + ing_str = ", ".join([i['name'] for i in current_ingredients]) + return render_template('recipe/edit.html', recipe=recipe, ingredients=ing_str) @recipe_bp.route('/recipe//delete', methods=['POST']) def delete_recipe(id): - """ - POST: 刪除指定的食譜。需再三確保為本人操作。刪除後預設連鎖會移除多對多食材關聯表內對應資料。 - 重導向至我的食譜列表首頁。 - """ - pass + if not session.get('user_id'): + flash('請先登入', 'danger') + return redirect(url_for('auth.login')) + recipe = Recipe.get_by_id(id) + if not recipe or recipe['user_id'] != session['user_id']: + flash('權限不足', 'danger') + return redirect(url_for('recipe.index')) + Ingredient.clear_recipe_ingredients(id) + Recipe.delete(id) + flash('刪除成功', 'success') + return redirect(url_for('recipe.my_recipes')) \ No newline at end of file diff --git a/app/static/css/.gitkeep b/app/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/static/images/.gitkeep b/app/static/images/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/static/js/.gitkeep b/app/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/templates/admin/.gitkeep b/app/templates/admin/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 00000000..efe91ad3 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block content %} +
+

🛡️ 系統後台總覽

+ +
👤 會員清單 (Total: {{ users|length }})
+
    + {% for u in users %} +
  • {{ u.username }}
  • + {% endfor %} +
+ +
🍳 全站食譜控管 (Total: {{ recipes|length }})
+ + + + + + {% for r in recipes %} + + + + + {% endfor %} + +
名稱操作
{{ r.title }} +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/.gitkeep b/app/templates/auth/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 00000000..5994debc --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

歡迎回來 👋

+

請登入以存取您的專屬料理空間

+
+ +
+
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+

還沒有帳號嗎? + 免費註冊 +

+
+
+
+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 00000000..12b10ca2 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

建立新帳號 ✨

+

加入我們,開始打造您的個人食譜庫

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+

已經有帳號了? + 立即登入 +

+
+
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 00000000..97b5f784 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,186 @@ + + + + + + 食譜收藏夾 Recipe Hub + + + + + + + + + + + + + + + +
+ +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+
+ + + {% block content %}{% endblock %} +
+ + + + + diff --git a/app/templates/recipe/.gitkeep b/app/templates/recipe/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/templates/recipe/detail.html b/app/templates/recipe/detail.html new file mode 100644 index 00000000..9f42bf4a --- /dev/null +++ b/app/templates/recipe/detail.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} +
+

{{ recipe.title }}

+

由 {{ author.username }} 建立 + {% if not recipe.is_public %}私密{% endif %} +

+ +
所需食材
+
    + {% for ing in ingredients %} +
  • {{ ing.name }}
  • + {% else %} +
  • 無食材資訊。
  • + {% endfor %} +
+ +
料理步驟
+
{{ recipe.steps }}
+ +
+ 返回列表 + {% if session.user_id == recipe.user_id %} + 📝 編輯食譜 + {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/recipe/edit.html b/app/templates/recipe/edit.html new file mode 100644 index 00000000..c2bc8f8e --- /dev/null +++ b/app/templates/recipe/edit.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} +
+

編輯食譜 📝

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + 取消 +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/recipe/index.html b/app/templates/recipe/index.html new file mode 100644 index 00000000..fe16a292 --- /dev/null +++ b/app/templates/recipe/index.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block content %} +

最新公開食譜 🍴

+
+ + +
+
+ {% for r in recipes %} +
+
+
{{ r.title }}
+

{{ r.steps[:30] }}...

+ 觀看食譜 +
+
+ {% else %} +

目前沒有發現任何資料。

+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/recipe/ingredient_search.html b/app/templates/recipe/ingredient_search.html new file mode 100644 index 00000000..a0efb103 --- /dev/null +++ b/app/templates/recipe/ingredient_search.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} +
+

從食材找靈感 🍅🥚

+

打開冰箱,擁有哪些食材?輸入後我們會幫您篩選可用食譜。

+
+ + +
+
+{% if items %} +
尋找包含 {{ items|join(' 和 ') }} 的結果:
+
+ {% for r in recipes %} +
+
+
{{ r.title }}
+ 觀看 +
+
+ {% else %} +

找不到包含該些食材的組合!

+ {% endfor %} +
+{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/recipe/my_recipes.html b/app/templates/recipe/my_recipes.html new file mode 100644 index 00000000..c87346cf --- /dev/null +++ b/app/templates/recipe/my_recipes.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} +

我的食譜庫

+
+ {% for r in recipes %} +
+
+
{{ r.title }} {% if not r.is_public %}私密{% endif %}
+ {{ r.created_at }} +
+
+ 檢視 + 編輯 +
+ +
+
+
+ {% else %} +

您尚未建立任何食譜。

+ {% endfor %} +
+ +{% endblock %} \ No newline at end of file diff --git a/app/templates/recipe/new.html b/app/templates/recipe/new.html new file mode 100644 index 00000000..7affe232 --- /dev/null +++ b/app/templates/recipe/new.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +
+

紀錄新食譜 ✍️

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/recipe/search_results.html b/app/templates/recipe/search_results.html new file mode 100644 index 00000000..e24be8c7 --- /dev/null +++ b/app/templates/recipe/search_results.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block content %} +

搜尋結果: '{{ keyword }}'

+
+ {% for r in recipes %} +
+
+
{{ r.title }}
+ 觀看食譜 +
+
+ {% else %} +

找不到符合的食譜。

+ {% endfor %} +
+返回首頁 +{% endblock %} \ No newline at end of file diff --git a/instance/.gitkeep b/instance/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..c1f961b1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask +python-dotenv +bcrypt diff --git a/run.py b/run.py new file mode 100644 index 00000000..b37535cb --- /dev/null +++ b/run.py @@ -0,0 +1,7 @@ +from app import create_app +import os + +app = create_app() + +if __name__ == '__main__': + app.run(debug=True) diff --git a/test_app.py b/test_app.py new file mode 100644 index 00000000..c92c8181 --- /dev/null +++ b/test_app.py @@ -0,0 +1,75 @@ +import os +from app import create_app +from app.models.database import init_db +import sys + +# Remove old db if exists to start fresh +if os.path.exists('instance/database.db'): + os.remove('instance/database.db') + +init_db() +app = create_app() +app.testing = True +client = app.test_client() + +print("[1] Testing Index (GET /)...") +res = client.get('/', follow_redirects=True) +assert res.status_code == 200, "Index failed" +print("OK Index works.") + +print("[2] Testing User Registration (POST /auth/register)...") +res = client.post('/auth/register', data={ + 'username':'test_chef_1', + 'password':'mypassword', + 'confirm_password':'mypassword' +}, follow_redirects=True) +assert res.status_code == 200 +print("OK Registration works.") + +print("[3] Testing User Login (POST /auth/login)...") +res = client.post('/auth/login', data={ + 'username':'test_chef_1', + 'password':'mypassword' +}, follow_redirects=True) +assert res.status_code == 200 +assert b'test_chef_1' in res.data, "Login failed to set session / show username." +print("OK Login works.") + +print("[4] Testing Recipe Creation (POST /recipe/new)...") +res = client.post('/recipe/new', data={ + 'title': 'Test Tomato Eggs', + 'steps': '1. Beat eggs\\n2. Fry tomatoes\\n3. Mix', + 'ingredients': 'Tomato, Egg, Salt', + 'is_public': 'on' +}, follow_redirects=True) +assert res.status_code == 200 +# Depending on UTF-8 handling in test response, checking exact bytes of 'Test Tomato Eggs' should work because it's ascii +assert b'Test Tomato Eggs' in res.data, "Recipe detail page didn't show newly created title" +assert b'Tomato' in res.data, "Ingredient missing" +print("OK Create recipe works.") + +print("[5] Testing Ingredient Search (GET /search/ingredients)...") +res = client.get('/search/ingredients?items=Egg', follow_redirects=True) +assert res.status_code == 200 +assert b'Test Tomato Eggs' in res.data, "Ingredient search didn't find the recipe containing 'Egg'" +print("OK Ingredient Search works.") + +print("[6] Testing Recipe Update (POST /recipe/1/edit)...") +res = client.post('/recipe/1/edit', data={ + 'title': 'Upgraded Tomato Eggs', + 'steps': '1. Beat eggs carefully...', + 'ingredients': 'Tomato, Egg, Salt, Scallion', + 'is_public': 'on' +}, follow_redirects=True) +assert res.status_code == 200 +assert b'Upgraded Tomato Eggs' in res.data +print("OK Update recipe works.") + +print("[7] Testing Recipe Delete (POST /recipe/1/delete)...") +res = client.post('/recipe/1/delete', follow_redirects=True) +assert res.status_code == 200 +print("OK Delete recipe works.") + +print("--------------------------------------------------") +print("All MVP tests (Step 5 verification) passed successfully!") +sys.exit(0) From 3048696cc2020774832506545aa0e0396299e745 Mon Sep 17 00:00:00 2001 From: bingr Date: Thu, 16 Apr 2026 21:09:00 +0800 Subject: [PATCH 12/12] feat: refactor recipe system into divination and fortune-telling system --- .agents/skills/commit/SKILL.md | 125 +++++++++++++++ .gitignore | Bin 217 -> 80 bytes __pycache__/run.cpython-314.pyc | Bin 0 -> 373 bytes app/__init__.py | 8 +- app/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 1320 bytes .../__pycache__/database.cpython-314.pyc | Bin 0 -> 2568 bytes .../__pycache__/divination.cpython-314.pyc | Bin 0 -> 3414 bytes .../__pycache__/donation.cpython-314.pyc | Bin 0 -> 2117 bytes .../__pycache__/ingredient.cpython-314.pyc | Bin 0 -> 7768 bytes app/models/__pycache__/recipe.cpython-314.pyc | Bin 0 -> 6357 bytes app/models/__pycache__/user.cpython-314.pyc | Bin 0 -> 4892 bytes app/models/divination.py | 59 ++++++++ app/models/donation.py | 34 +++++ app/models/ingredient.py | 143 ------------------ app/models/recipe.py | 130 ---------------- app/routes/__init__.py | 12 +- .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 196 bytes app/routes/__pycache__/admin.cpython-314.pyc | Bin 0 -> 2553 bytes app/routes/__pycache__/auth.cpython-314.pyc | Bin 0 -> 4920 bytes .../__pycache__/divination.cpython-314.pyc | Bin 0 -> 6221 bytes .../__pycache__/donation.cpython-314.pyc | Bin 0 -> 2668 bytes app/routes/__pycache__/recipe.cpython-314.pyc | Bin 0 -> 8997 bytes app/routes/admin.py | 25 --- app/routes/auth.py | 2 +- app/routes/divination.py | 86 +++++++++++ app/routes/donation.py | 40 +++++ app/routes/recipe.py | 116 -------------- app/templates/admin/.gitkeep | 0 app/templates/admin/dashboard.html | 32 ---- app/templates/base.html | 133 ++++++++-------- app/templates/divination/history.html | 51 +++++++ app/templates/divination/index.html | 37 +++++ app/templates/divination/result.html | 37 +++++ app/templates/divination/tarot_draw.html | 27 ++++ app/templates/divination/temple_draw.html | 28 ++++ app/templates/donation/donate.html | 34 +++++ app/templates/donation/thanks.html | 30 ++++ app/templates/recipe/.gitkeep | 0 app/templates/recipe/detail.html | 28 ---- app/templates/recipe/edit.html | 28 ---- app/templates/recipe/index.html | 21 --- app/templates/recipe/ingredient_search.html | 26 ---- app/templates/recipe/my_recipes.html | 26 ---- app/templates/recipe/new.html | 25 --- app/templates/recipe/search_results.html | 17 --- database/schema.sql | 39 ++--- docs/DB_DESIGN.md | 112 ++++++-------- docs/ROUTES.md | 106 +++---------- instance/database.db | Bin 0 -> 24576 bytes test_app.py | 75 --------- ...46\344\275\234\350\252\252\346\230\216.md" | 2 +- 51 files changed, 756 insertions(+), 938 deletions(-) create mode 100644 __pycache__/run.cpython-314.pyc create mode 100644 app/__pycache__/__init__.cpython-314.pyc create mode 100644 app/models/__pycache__/database.cpython-314.pyc create mode 100644 app/models/__pycache__/divination.cpython-314.pyc create mode 100644 app/models/__pycache__/donation.cpython-314.pyc create mode 100644 app/models/__pycache__/ingredient.cpython-314.pyc create mode 100644 app/models/__pycache__/recipe.cpython-314.pyc create mode 100644 app/models/__pycache__/user.cpython-314.pyc create mode 100644 app/models/divination.py create mode 100644 app/models/donation.py delete mode 100644 app/models/ingredient.py delete mode 100644 app/models/recipe.py create mode 100644 app/routes/__pycache__/__init__.cpython-314.pyc create mode 100644 app/routes/__pycache__/admin.cpython-314.pyc create mode 100644 app/routes/__pycache__/auth.cpython-314.pyc create mode 100644 app/routes/__pycache__/divination.cpython-314.pyc create mode 100644 app/routes/__pycache__/donation.cpython-314.pyc create mode 100644 app/routes/__pycache__/recipe.cpython-314.pyc delete mode 100644 app/routes/admin.py create mode 100644 app/routes/divination.py create mode 100644 app/routes/donation.py delete mode 100644 app/routes/recipe.py delete mode 100644 app/templates/admin/.gitkeep delete mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/divination/history.html create mode 100644 app/templates/divination/index.html create mode 100644 app/templates/divination/result.html create mode 100644 app/templates/divination/tarot_draw.html create mode 100644 app/templates/divination/temple_draw.html create mode 100644 app/templates/donation/donate.html create mode 100644 app/templates/donation/thanks.html delete mode 100644 app/templates/recipe/.gitkeep delete mode 100644 app/templates/recipe/detail.html delete mode 100644 app/templates/recipe/edit.html delete mode 100644 app/templates/recipe/index.html delete mode 100644 app/templates/recipe/ingredient_search.html delete mode 100644 app/templates/recipe/my_recipes.html delete mode 100644 app/templates/recipe/new.html delete mode 100644 app/templates/recipe/search_results.html create mode 100644 instance/database.db delete mode 100644 test_app.py diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md index e69de29b..efe46e02 100644 --- a/.agents/skills/commit/SKILL.md +++ b/.agents/skills/commit/SKILL.md @@ -0,0 +1,125 @@ +--- +name: commit +description: 提交並推送程式碼變更。用於每個開發階段完成後,將成果儲存到 Git 並推送到 GitHub。 +--- + +# Commit Skill — 提交與推送程式碼 + +這個 skill 會引導 AI agent 幫你完成 Git 提交與推送,並在必要時設定 Git 身份識別資訊。 + +## When to use this skill + +- 完成某個開發階段後,想要儲存進度 +- 要把程式碼推送到 GitHub,讓組員可以取得最新版本 +- 遇到 Git 要求設定 `user.name` 或 `user.email` 的提示 + +## 檢查 .gitignore +在推送之前檢查 .gitignore,確保虛擬環境、環境設定與非必要檔案有正確被排除在版本控制之外。 + +## ⚠️ 設定 Git 使用者身份 + +如果 Git 顯示以下提示,需要先設定身份才能 commit: + +``` +Author identity unknown + +*** Please tell me who you are. + +Run + + git config --global user.email "you@example.com" + git config --global user.name "Your Name" +``` + +請使用 `antigravity` 作為 username 與 email: + +```bash +git config --global user.name "antigravity" +git config --global user.email "antigravity" +``` + +> 這樣設定後,之後所有 commit 都不需要再重新設定。 + +## How to use it + +### 基本用法:提交並推送 + +``` +請幫我將目前的變更提交並推送到 GitHub。 + +commit 訊息:[描述這次做了什麼,例如:docs: add PRD] +``` + +AI 會執行以下步驟: +1. 確認 Git 使用者身份是否已設定(若無則設定為 `antigravity`) +2. `git add .` — 加入所有變更 +3. `git commit -m "[你的訊息]"` — 建立 commit +4. `git push` — 推送到 GitHub + +--- + +### 完整提示語(可直接複製使用) + +``` +請幫我提交並推送目前的變更: + +1. 如果尚未設定 Git 身份,請先執行: + git config --global user.name "antigravity" + git config --global user.email "antigravity" + +2. 然後執行: + git add . + git commit -m "[在此填入 commit 訊息]" + git push + +完成後告訴我推送成功,並顯示 commit 的 hash 值。 +``` + +--- + +## Commit 訊息格式建議 + +遵循以下格式,讓歷史紀錄更清楚: + +| 類型 | 使用時機 | 範例 | +| ------ | --------------------- | ---------------------------- | +| `docs` | 新增或修改文件 | `docs: add PRD` | +| `feat` | 新增功能 | `feat: add task create form` | +| `fix` | 修復錯誤 | `fix: form validation error` | +| `chore`| 設定、初始化等雜項 | `chore: init project` | + +--- + +## 各開發階段建議的 commit 訊息 + +| 階段 | 建議 commit 訊息 | +| ---------- | ----------------------------------------- | +| 階段一完成 | `docs: add PRD` | +| 階段二完成 | `docs: add system architecture` | +| 階段三完成 | `docs: add user flowchart` | +| 階段四完成 | `feat: add database schema and models` | +| 階段五完成 | `feat: add route skeleton and template plan` | +| 階段六完成 | `feat: implement [功能名稱]` | + +--- + +## 常見問題 + +**Q: push 時要求輸入帳號密碼?** + +GitHub 已停止支援密碼驗證,請使用 Personal Access Token(PAT): +1. 到 GitHub → Settings → Developer settings → Personal access tokens +2. 產生一個新 token(勾選 `repo` 權限) +3. push 時,「密碼」欄位貼上 token 即可 + +**Q: push 被拒絕(rejected)?** + +可能是遠端有別的組員更新過,先執行: +```bash +git pull --rebase +git push +``` + +**Q: 不小心 commit 了不該 commit 的檔案?** + +告訴 AI:「我剛才不小心 commit 了 [檔案名稱],請幫我移除。」 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6e6883fc52032e221ed05769245f5657a0c8a96f..c1e69bb0da99e954ab9b341cde9d40f5c8146d14 100644 GIT binary patch literal 80 zcmezWPmiIDA(bHyNa{22GH@|u0@=k3B@Br`IvFSf5!HjL0jY=w!UBd$pll)#X8>hD HA`o=|ikc1i literal 217 zcmXYr!3x4K5Jd0$6@hvxnEZpb2cdWodJzd}vaNwO3E2>>UvDBFb{QVCJJYb!p75ys z7VL_K3FbqxoBjNBHwjEf(~K{Vl`)k@a2J~D1)&k+e?s;CgUxxw6kO?52%6A?d2F4K&bYEpRT9je7MOg>tcM z+*74KoJ(Xkc~CFF+3_#V6-AHe(H7Ewywv7@{})HoV;gax_VFNoX=qR%8Yd$|SA)$K ztq_Y&ndKrUKAmKgk3&16bjJ2MaYbt)W|>cuya#O3vMaV@a@%$4iiG8^iMK+1!V}!$ zWmY5}qf_?Gtyo{vC6i*Jk3p0W$1G7o0@d|{aDj@R2jNNAnX(H(v}E3(md*SiX*)hQ zok>bOlTQb2rswrCx6G;EE6!mt0k(CWwv`+cO$)ZHw>9!)31QjHXaW9@GLkQrsmE-G zXKC!y3?;`U(U{TeHa+|*nOco_-Od<@Z;O_xN9|xLXk%!_eP#+i|y(8_VnDj zPx#Ou(C)9{!!_Kuj3bM~Tz^EE=p0K4A_67CJ0t@n wZV){INtBE96x$7UaiG5w$S+M%lp5-&p>EnlPyWX3O55sQRY}S4ztJ=%`~Uy| literal 0 HcmV?d00001 diff --git a/app/models/__pycache__/database.cpython-314.pyc b/app/models/__pycache__/database.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c12af9fc489ef27b8c739126d389f77ad9555d7b GIT binary patch literal 2568 zcma)-TTB#J7=X|0?6Sk&00r?{Mu^f46fB^tc-eAkXcLunYU#_=>9RZE$g;DZ8AN$u zOWFuZT}iB{trw~mtg+G9Mx&-lmDeWJ)o4Z@OmqdFYAT71zVtu4JG*GLdS>^WfBwt4 z&H2Am;;>r~l#_qlmv$HtdO%+EVonNAegh$lwjhrB7^#VtQmM}=4@A6NJbKg&!)rV< zXSgKA*LYfA;h9lICA50W8LA8kjcVrTe55j*5$a5iDnd@v6fte=nqO8{4nHZVsNSGY zEcbg=Z?jhs%lys0L~x=uK|>>Ce6kKk38MvwPgy<5QBn#J7SFW5<*x9I-a~2y6lZWH zdoMtI{L2s@k4^VXw=m*=;zKiI9BE%&YC^$OOcV0V*7fO@qxHEIpe9s=Sj6Y*)(qs& zLueriQGC9x%p*qqZW>6r$XeL`i5hy$DH^Goxb<_*-IEuS}fX`|$FQvG0z> zdM-Y^9FB$ePWJ9*KiRZiQbl(1;_gQW567;YojCHma$oG+VC?J5Q{Vg= z3tx+!I~0o?Omt7W-0Y+@M%*bWsvx&@&Gm3K4I66M78#3DYfxwtcPZ>zwsB+Eg8Jm) zV105hDEd@M4zeP~GOlJfI&GRkRy3yF+a~%YtZ2rNSKX?aK@NJ`Ma^7aD|l))f25g{ zlhn+~UNjn}t668PMKg;#MPG+1YMCGI^ob!dkY);DDX2OPnvuk5l&FxC!Lrj^psB0o zKUGAm@Xb=N74wZju^vl1ME=oG|J2bR_|L>}_uc2Vq&&vQ>O1D7~SO;1_p@ z0Xftz22~#3`F7ba1{6M(387tDPOGR2{$?S)ZcL6r2K<%nP=(QB3uAJOGnR;TkM%(9 zV8cMeVB3Q`wvt~SNPNaKJ_xGLAtm0nd_=3XYs}Ha4HQr+8-eqhNTc2$# zulyFX>~7ZVUh6;O*4$}iApP0LvP(v4Mk>b~aVukg{@4VDXUbd{wWp@KW-a=Ct+jR` zeVxgw&8M&Dn?QacaS49niOa!u#S=G)352S^L<*!yDuv$`VQ+|d1~zP-ZR6i zfzf2}02E#>`cCWP7625;QnjrY0ff!h;{jnH1Hx#2k*?!1Hq$R6Y!N{i?WV~X326q*%pEllX^Hh7jScUO z^(V0c)B>_h{u~C-u!?W1*zPTZyk`mRj$fJ_>Ie8u4vkFybSxG*O&HWQFL4p+Sy!G8 z(kR?z>QGzWTN&q6&?oyLhc|W=q;Mn!B~|hUq%QAFBssI}b}+tc5uGs1`g6lnS~2SSXm>FPfQ57FdFd;Efy=Kd_0SdW)%V- z=FA;z9cUd43(ZOnrQ_@1X6N3?F8njQ@WlJWD~48_TzMJqiazZhINa7Qcy*h)QK#dZ+$gBZMcLc|6k z%=;Ewq`0p*QrcI#9}jj8bPje8bf3V(okN|&-9z2ycSegpi00Rf)d^9{7h&_?Mmy!0 Ne#cHN$4j82e*wV8IFtYY literal 0 HcmV?d00001 diff --git a/app/models/__pycache__/divination.cpython-314.pyc b/app/models/__pycache__/divination.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d312903d01d59acd8474efde634b420fd09be73 GIT binary patch literal 3414 zcmcgvU2GIp6uvXFvwz)|?rwM61!~6>DqAapZ4{MK7j}`KvXJRgG!Bg0-2o1?vF;^-UrJN?0iaE=j@nNk(cV2~V7QF998pR`x z-ot226qV3!7f-dKK5LD}LLc_C`)=qnj=l%_E=S+Xvzl)hX@1@fQF(NKQa&gbvql`O z<&=DQ;h#Y$a3=t9kE10>An_ZmwwJ;VvpZnU{mmYC54(3beOeQFY8SPubJ*3()^gN= zz!PNo{#Nq@270*pzxdda6vidY<*)>&R@~><;W;iH_hND)x;t0KY69CY5 zYZ4erqocB>`wCfA!=*!VUT1TKk}B!G)Zv_@kT`T!!E#ZH`*e>LsqTgNMG~#gn>!yaTjhOdYFtc05u!|O(j?&pvF;R_YqDXIYL0+;((oP zC8w_L2LZ@)dmP9MMbGz~?x}XX_;uIUv8m9|&5&?CBus_&Ol+{R^#j*=w(lM0%(K=9D)zDKn0=*8LJp!8@OEe}zsM1Uk8EQy8%~aZ(604cYY8Lc5w7Frr zfi^#2@E_Rze?eO_+R2pZm8eW_Y(hw39C%}D{G6t@HY4OikH9Pi7`jY%7@AC&-ig5_ z2GhG77^E|gQ&_1Z@RNjOzVHks#O$;|#Xst1SY$1@XA(9gH5C{{^QJY7@t>@RCUR&k% zR%NBSA5VqVo8hC^!$+sWuLH~0xboZO@u_h4rOZX@Z1SDpxfd^Gu7|s;f$l%Q33u0^ z9W^L)&9&e)rW#s%BhWt`j-1?VkZAOt`XuaqY#`B&DpX6N%~greL~mn>Y;RakGnEyK z68%i&8H(urEXX>v+uT|;zQlj$R(O|C593#3{Gt46F{k~uv6+(=>~{??CtZ(PK&(rl z2WXxJ%o>^lv+lCNoD-kie)pp~gf2@0b7oQx=gmy*eF0aiw17C%8{_VI>cr$SFi|(p zm&`n0nm8lx5x|*=GPmKj{&qG^t6-Y?l5rq&Zm%z9qY7{{I5}924Sd=4W$Z>^D*=*u z5!;9gx7L6YTX^@j8L)}nQ(M9LvHk=FuxUsvXDWfF1jkf37W9u%I^LjrMX{J2l|)hZ ziy}NO$_1haMDaj5Td+nJiQ--vt6D)WO2rZxRyBBszGN}%jJ7Xydp9yrF5~u;RkFuyv6h?0%fo??t(@VQf z7A>&rP>frkU>>9F;Fw~G*%49JHBFH%Ro7(K(UnYPvJ?kJg6zkTMG45Fk24n2f^>9@ z6*z%vMLDXs;%afQ27A`#9)YpEX=M4FojtZMu4|IBQ#Ct@^uRyN150&K*#v4s8B(D$ zs+kNX%?vG%dD+gU*&(j7xa=cPN2oNtwRQ)c2=aKBIlPny?Cg;5RlC7K+^SdCtMx$~ zw+`_r6R7%lQ1%Uk)*{1`X{@dZbjlMzTj<`(`8rWMypQ4OgL_(6(Ns!B+nIKxQZcr` zLu6ff`riEQuYdn(;_2Pb7jE2{pT1eXbh-T9{ii=nEKHuS8{ajM98L%+eju3|g)-(3%T} zo1<+D$XFv4;Mg z%& zROgH|Q^kFfKG1}aA9aA20mv`VosOmw207i11OS8D90swOdN%`tj+SCqGKlQtu!!x0 z?gngv?+OmBhuHb4>G}KD%imls-~4R;>dey{=gW70oS*vMrISmf@!i9TgNZ#UzMJ1G z3?8bHz~qnYO9%k`*P3k0*xrjKAeF2|DWvBANUs@QY+1 zl=3>1!lcdEzEQ=JPe{2OCf&tmv#M-i(nK&hbBx1qi3kT>o35EQmBk^LnWWh{1i`C` zv4J%6k+WizXRS3TuQU)qnWsSjq&;4LOAXSY*6Ta2>?rct;+R$(HReJmABDyrgvRGW zp90{k!2M-IY%bJ2le$G+i+>rM{&*(!Ak_h;Dgdz6*Pw>+5evv()PT2s<05i5}sIY1rm$={(9poZQ4DvKD5^XUG+8?x^YjM{8bTn{xqmwbl_GKkY8j%1lB$u1HgJ@ku lIPdM-;hy><+zo4_46_-;B12Ks6ZH09Y$w&Sgg`hk{{mld1gro6 literal 0 HcmV?d00001 diff --git a/app/models/__pycache__/ingredient.cpython-314.pyc b/app/models/__pycache__/ingredient.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf50e610412fe9e9c89559878ab2e4ac661e10c8 GIT binary patch literal 7768 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 Q6@wU`m>C%vi})&Kwi literal 0 HcmV?d00001 diff --git a/app/routes/__pycache__/admin.cpython-314.pyc b/app/routes/__pycache__/admin.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6225089a9cfd4c1714f9ce23e36b15281b62c52f GIT binary patch literal 2553 zcmah~TWl0n7(R2`OSj$a4XRMuDdm!-u;tQlOCb?jx`omgz48?3E1{UZ!%^gNQ7P84JeCde9WkTbX-Z}36E5P}ZF z5p){PdaA>Ax`J-QZOysPlAy=%)T4t;1CscIr7dj!N~25?YLMYQSk_yg-3^KAkjfg7-#bZf^*c@X7A=L7M2#D!kKnk1 zN!Y-#q$V5KbQ1hR3hRdHB3MxgjvA&jMYQlhf|$a9ChJ3{Q^&flCgOg^bVxX=CNa$T z-8u#hS&6CfC^O4L4(9ZmUJIQ(E4E3d9`!@4`cO+jzaOn(B(?@=F5B-U+s}gK9HG&H zSA-)(PU=xu6dTPPjqCKc(6usY6e@~(3o$Kjzyxwrd~az%pe#VB_UY#s+fMu3V1sqe z#tz^?;_R%~qma{nH{`Zw5^vvOkBz90Z9x5~8Dg=8;SqQ%K*S-KVO23*svfpNnyP~< z?tlLE{Vy)v|McAb(N}YCoy%Pu$z8gTdo!IIAOG#j3Dcp-@j*<6OCf8JlzQ-S2pxeMg<&E-f^PwSn2|vG-=i;%N>} zm(A?kGg}Ve_t|?Aw~s4FyZICwKq+<`gfz*ADr{+gu^_tH9pwnQQ5|R{d5K>~{e{*D z>w0TXh0?HzpaS3=RwChOA|3~h0{9HeeeGTCTX%^huxz=Q3eXaY%P}l&lX`ZF`I68@ zB5v>L>=vm|7gJ(SH>ekiP22>hR7LEO+S(;?%RZ1q3AP|A8)92~-`3%3E7{EouwW!1 z6D2TY#5916VBJT#lNO%tcM}>s(@Bk#HO;Jo5(vZ+3fA<1A{%lJe$ngHf?m)uYo1WUNr!L)g!&`YqdriyCKc4lz^anz|PWFM-LoN7ru<4E) zyzA8V6Wd?uy6c&jnZN4toXmbX8;o4tFK2f5e_#Eb{6h`QuLdI%o@j=T{&g)F(J8-t zQ(OJq`qJT==6pn&Tj5``QB^iKLkKW<%lG#5?RxAtK|Qe$9=B|M8j1H8NgS{?L%{Ic zehL8>@onRr{$5h^;nCccuW}<_=0?s>zVqSa2jjnfa`xWHXzuj*o%Bi52?QAjk{kBq z8O)|uT9?4PmQzo%D`T>%qSIg7l6-c`@_ImU6eVOeNGPl8P0(3c5UQaDtz6 zi+5*AmVWf?1i$8%r|RvFv7OoKWm!*ChHtV6quXDZ{~aG9G-?)F2#v!Gz31(*$iHG1 z{tB;KmiuPyQwZl_>k5Zy;e^AcD^G5bq(y6{V6nIbFU$Imk!4HXJ69_JJf-2yWFweC z-PVsoH^ngw^Ap;89qqk}_TEN|ZX@v*Pxa`Yv7xMIY1;LWcQJKS)rc)QRd=FplpDii zd(w4TX7LTi_keXX3ImkMmeX#@6i?Sva|_Cua{t9`7uQqmR!f^>mKT*bJ>+3&+4m33 C^-W>` literal 0 HcmV?d00001 diff --git a/app/routes/__pycache__/auth.cpython-314.pyc b/app/routes/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e3ecc0dd0b6d927eaaa03cd1c320310dd1ee4c9 GIT binary patch literal 4920 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/delete', methods=['POST']) -def delete_recipe(id): - if not session.get('user_id') or not session.get('is_admin'): - flash('您未具備管理員權限!', 'danger') - return redirect(url_for('recipe.index')) - Ingredient.clear_recipe_ingredients(id) - Recipe.delete(id) - flash('已強制刪除違規資料。', 'success') - return redirect(url_for('admin.index')) \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index cdd5556e..7ea5cb2a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -72,7 +72,7 @@ def login(): session['username'] = user['username'] session['is_admin'] = user['is_admin'] flash('登入成功!', 'success') - return redirect(url_for('recipe.index')) + return redirect(url_for('divination.index')) flash('帳號或密碼錯誤!', 'danger') return redirect(url_for('auth.login')) diff --git a/app/routes/divination.py b/app/routes/divination.py new file mode 100644 index 00000000..89aac418 --- /dev/null +++ b/app/routes/divination.py @@ -0,0 +1,86 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +import random +from app.models.divination import Divination + +divination_bp = Blueprint('divination', __name__) + +def login_required(f): + from functools import wraps + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + flash('請先登入以使用專屬占卜與完整紀錄功能!', 'warning') + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + return decorated_function + +@divination_bp.route('/', methods=['GET']) +def index(): + return render_template('divination/index.html') + +@divination_bp.route('/divination/temple', methods=['GET', 'POST']) +@login_required +def temple_draw(): + if request.method == 'POST': + question = request.form.get('question', '未填寫問題') + + # Simulate simple 60-jiazi draw + draw_number = random.randint(1, 60) + result_title = f"觀音靈籤 第 {draw_number} 籤" + explanation = f"針對您的問題:「{question}」。\n這支籤代表著順流而行,切莫急躁。萬事互相效力,時間到了自然會有好的結果展開。建議您保持內心的平靜,多做善事累積福報。神明指示您只需依循本心,凡事不強求。" + + div_id = Divination.create(session['user_id'], 'temple', question, result_title, explanation) + if div_id: + flash('求籤成功!神明已經給予了指引。', 'success') + return redirect(url_for('divination.result', id=div_id)) + else: + flash('系統發生錯誤,無法儲存。', 'danger') + + return render_template('divination/temple_draw.html') + +@divination_bp.route('/divination/tarot', methods=['GET', 'POST']) +@login_required +def tarot_draw(): + if request.method == 'POST': + question = request.form.get('question', '未填寫問題') + + # Simple Major Arcana simulate + tarot_cards = ["愚者 (The Fool)", "魔術師 (The Magician)", "女祭司 (The High Priestess)", "皇后 (The Empress)", "皇帝 (The Emperor)", "教皇 (The Hierophant)", "戀人 (The Lovers)", "戰車 (The Chariot)", "力量 (Strength)", "隱者 (The Hermit)", "命運之輪 (Wheel of Fortune)", "正義 (Justice)", "太陽 (The Sun)", "世界 (The World)"] + card = random.choice(tarot_cards) + position = random.choice(["正位", "逆位"]) + result_title = f"{card} - {position}" + + explanation = f"針對您的問題:「{question}」。\n您抽到了充滿靈性的代表牌。這象徵著您正面臨一個關鍵的轉折點,傾聽自己內心的聲音將是最佳解答。宇宙正在暗中協助您度過難關,請保持正能量。" + + div_id = Divination.create(session['user_id'], 'tarot', question, result_title, explanation) + if div_id: + flash('塔羅抽牌完成!宇宙傳遞了深層的訊息。', 'success') + return redirect(url_for('divination.result', id=div_id)) + else: + flash('系統發生錯誤,無法儲存。', 'danger') + + return render_template('divination/tarot_draw.html') + +@divination_bp.route('/divination/result/', methods=['GET']) +@login_required +def result(id): + div = Divination.get_by_id(id) + if not div or div['user_id'] != session['user_id']: + flash('找不到該紀錄或您沒有權限。', 'danger') + return redirect(url_for('divination.index')) + return render_template('divination/result.html', div=div) + +@divination_bp.route('/divination/history', methods=['GET']) +@login_required +def history(): + divs = Divination.get_by_user_id(session['user_id']) + return render_template('divination/history.html', divs=divs) + +@divination_bp.route('/divination//delete', methods=['POST']) +@login_required +def delete(id): + div = Divination.get_by_id(id) + if div and div['user_id'] == session['user_id']: + Divination.delete(id) + flash('紀錄已成功刪除。', 'info') + return redirect(url_for('divination.history')) diff --git a/app/routes/donation.py b/app/routes/donation.py new file mode 100644 index 00000000..07ac4be6 --- /dev/null +++ b/app/routes/donation.py @@ -0,0 +1,40 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, session +from app.models.donation import Donation + +donation_bp = Blueprint('donation', __name__) + +def login_required(f): + from functools import wraps + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session: + flash('請先登入會員以完成線上隨喜程序。', 'warning') + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + return decorated_function + +@donation_bp.route('/', methods=['GET']) +def donate(): + return render_template('donation/donate.html') + +@donation_bp.route('/process', methods=['POST']) +@login_required +def process(): + amount = request.form.get('amount', type=int, default=0) + if amount <= 0: + flash('請輸入大於0的金額。', 'danger') + return redirect(url_for('donation.donate')) + + # Simulate payment processing MVP + don_id = Donation.create(session['user_id'], amount, status='completed') + if don_id: + return redirect(url_for('donation.thanks', amount=amount)) + else: + flash('處理發生錯誤。', 'danger') + return redirect(url_for('donation.donate')) + +@donation_bp.route('/thanks', methods=['GET']) +@login_required +def thanks(): + amount = request.args.get('amount', 0) + return render_template('donation/thanks.html', amount=amount) diff --git a/app/routes/recipe.py b/app/routes/recipe.py deleted file mode 100644 index c57921f6..00000000 --- a/app/routes/recipe.py +++ /dev/null @@ -1,116 +0,0 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, session -from app.models.recipe import Recipe -from app.models.ingredient import Ingredient -from app.models.user import User - -recipe_bp = Blueprint('recipe', __name__) - -@recipe_bp.route('/', methods=['GET']) -def index(): - recipes = Recipe.get_all_public() - return render_template('recipe/index.html', recipes=recipes) - -@recipe_bp.route('/search', methods=['GET']) -def search(): - q = request.args.get('q', '') - recipes = Recipe.search_by_keyword(q) - return render_template('recipe/search_results.html', recipes=recipes, keyword=q) - -@recipe_bp.route('/search/ingredients', methods=['GET']) -def ingredient_search(): - items_str = request.args.get('items', '') - if items_str.strip(): - items = [i.strip() for i in items_str.split(',') if i.strip()] - recipes = Ingredient.search_recipes_by_ingredients(items) - else: - items = [] - recipes = [] - return render_template('recipe/ingredient_search.html', recipes=recipes, items=items) - -@recipe_bp.route('/recipe/', methods=['GET']) -def detail(id): - recipe = Recipe.get_by_id(id) - if not recipe: - flash('食譜不存在', 'danger') - return redirect(url_for('recipe.index')) - if not recipe['is_public']: - if session.get('user_id') != recipe['user_id'] and not session.get('is_admin'): - flash('您沒有權限檢視此食譜', 'danger') - return redirect(url_for('recipe.index')) - ingredients = Ingredient.get_ingredients_for_recipe(id) - author = User.get_by_id(recipe['user_id']) - return render_template('recipe/detail.html', recipe=recipe, ingredients=ingredients, author=author) - -@recipe_bp.route('/recipe/my', methods=['GET']) -def my_recipes(): - if not session.get('user_id'): - flash('請先登入', 'danger') - return redirect(url_for('auth.login')) - recipes = Recipe.get_by_user_id(session['user_id']) - return render_template('recipe/my_recipes.html', recipes=recipes) - -@recipe_bp.route('/recipe/new', methods=['GET', 'POST']) -def new_recipe(): - if not session.get('user_id'): - flash('請先登入', 'danger') - return redirect(url_for('auth.login')) - if request.method == 'POST': - title = request.form.get('title') - steps = request.form.get('steps') - is_public = 1 if request.form.get('is_public') else 0 - ingredients_input = request.form.get('ingredients') - if not title or not steps: - flash('標題與步驟為必填', 'danger') - return redirect(url_for('recipe.new_recipe')) - recipe_id = Recipe.create(session['user_id'], title, steps, is_public, None) - if ingredients_input: - items = [i.strip() for i in ingredients_input.split(',')] - for item in items: - if item: - ing_id = Ingredient.create(item) - Ingredient.link_recipe_ingredient(recipe_id, ing_id) - flash('建立成功!', 'success') - return redirect(url_for('recipe.detail', id=recipe_id)) - return render_template('recipe/new.html') - -@recipe_bp.route('/recipe//edit', methods=['GET', 'POST']) -def edit_recipe(id): - if not session.get('user_id'): - flash('請先登入', 'danger') - return redirect(url_for('auth.login')) - recipe = Recipe.get_by_id(id) - if not recipe or recipe['user_id'] != session['user_id']: - flash('權限不足', 'danger') - return redirect(url_for('recipe.index')) - if request.method == 'POST': - title = request.form.get('title') - steps = request.form.get('steps') - is_public = 1 if request.form.get('is_public') else 0 - ingredients_input = request.form.get('ingredients') - Recipe.update(id, title=title, steps=steps, is_public=is_public) - Ingredient.clear_recipe_ingredients(id) - if ingredients_input: - items = [i.strip() for i in ingredients_input.split(',')] - for item in items: - if item: - ing_id = Ingredient.create(item) - Ingredient.link_recipe_ingredient(id, ing_id) - flash('更新成功!', 'success') - return redirect(url_for('recipe.detail', id=id)) - current_ingredients = Ingredient.get_ingredients_for_recipe(id) - ing_str = ", ".join([i['name'] for i in current_ingredients]) - return render_template('recipe/edit.html', recipe=recipe, ingredients=ing_str) - -@recipe_bp.route('/recipe//delete', methods=['POST']) -def delete_recipe(id): - if not session.get('user_id'): - flash('請先登入', 'danger') - return redirect(url_for('auth.login')) - recipe = Recipe.get_by_id(id) - if not recipe or recipe['user_id'] != session['user_id']: - flash('權限不足', 'danger') - return redirect(url_for('recipe.index')) - Ingredient.clear_recipe_ingredients(id) - Recipe.delete(id) - flash('刪除成功', 'success') - return redirect(url_for('recipe.my_recipes')) \ No newline at end of file diff --git a/app/templates/admin/.gitkeep b/app/templates/admin/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html deleted file mode 100644 index efe91ad3..00000000 --- a/app/templates/admin/dashboard.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-

🛡️ 系統後台總覽

- -
👤 會員清單 (Total: {{ users|length }})
-
    - {% for u in users %} -
  • {{ u.username }}
  • - {% endfor %} -
- -
🍳 全站食譜控管 (Total: {{ recipes|length }})
- - - - - - {% for r in recipes %} - - - - - {% endfor %} - -
名稱操作
{{ r.title }} -
- -
-
-
-{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 97b5f784..01c3ab88 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,47 +3,48 @@ - 食譜收藏夾 Recipe Hub + 線上祈福與算命占卜系統 - - + + @@ -110,47 +129,41 @@