Peugeot 408油電版購車紀錄:油耗紀錄APP

買新車就像新官上任一樣,什麼東西都想搞一下,而且換的是油電車,當然要記錄一下油耗。

雖然MyPeugeot APP會幫你同步每一次用車的油耗,但我也想知道每桶油的油耗表現怎麼樣。找了整個iOS App Store,不是功能太多用不到,不然就是簡體中文的名稱,再不然就是插了一堆廣告,完全找不到一個滿意的APP。

那不然就自己做吧?結果iOS的App需要Macbook才能編譯,上架流程看起來也很繁瑣,只好放棄這條路,改用前後端網頁架構來偽裝成一個APP好了,反正伺服器也是自己的,沒有寄人籬下的問題。

設計上很簡單,需要填的選項不多,不然加個油像在填問卷一樣。

可以選擇車輛,家裡有二台車,這裡可以分開管理。選取車輛後會提示上次加油的日期跟里程數,我有固定在使用汽油精,所以也會提示使用汽油精的里程數。

捲到最下方就可以看過去加油的紀錄,以圖表跟表格顯示。

新增紀錄的話先選擇加油站

再選擇油品

選完油品後會自動帶出當日的油價,不用自己填寫。

有里程數的檢查

最後是有沒有使用汽油精的選項,填完後按新增紀錄即可。

程式碼的部分,用的是python flask的前後端網頁套件,資料庫用最簡單的sqlite3。

程式啟動時會檢查兩個資料表是否存在,若不存在則新增

  • fuel_records 儲存加油日期、車牌、油品、單價、里程、加油量、成本與計算出的油耗效率。
  • fuel_price 則儲存各加油站油品的最新油價。

首頁路由/會顯示指定車牌的所有加油紀錄,並提供表單新增新紀錄,程式會自動計算與上次里程的差距及每公升的油耗。

/last_record在前端選擇車牌後執行,提供JSON格式的最新紀錄、油耗走勢與最近一次使用添加劑的里程,回傳到前端做繪圖和其他提示。

/get_stations在選擇加油站時會讀取加油站列表跟當日各油品的單價、/get_records取得指定車牌的所有加油紀錄以及/insert_record負責新增紀錄。

from flask import Flask, render_template, request, redirect, url_for, jsonify, send_from_directory
import sqlite3
from datetime import datetime

app = Flask(__name__)

DB_FILE = "fuel_records.db"
CAR_PLATES = ["XXX-1234(Peugeot 408)", "YYY-1234(KIA SPORTAGE)"]

def init_db():
    with sqlite3.connect(DB_FILE) as conn:
        c = conn.cursor()
        # fuel_records table
        c.execute("""
            CREATE TABLE IF NOT EXISTS fuel_records (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                plate TEXT,
                record_date TEXT,
                station TEXT,
                fuel TEXT,
                price REAL,
                odo_reading REAL,
                volume REAL,
                distance REAL,
                cost REAL,
                fuel_efficiency REAL,
                fuel_additives TEXT
            )
        """)
        # fuel_price table
        c.execute("""
            CREATE TABLE IF NOT EXISTS fuel_price (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                company TEXT,
                oil_type TEXT,
                price REAL,
                price_date TEXT,
                UNIQUE(company, oil_type)
            )
        """)
        conn.commit()

init_db()

def get_stations():
    stations = {}
    with sqlite3.connect(DB_FILE) as conn:
        conn.row_factory = sqlite3.Row
        c = conn.cursor()
        c.execute("SELECT company, oil_type, price FROM fuel_price")
        rows = c.fetchall()
        for r in rows:
            company = r["company"]
            oil = r["oil_type"]
            price = r["price"]
            if company not in stations:
                stations[company] = {}
            stations[company][oil] = price
    return stations

def get_records(plate=None):
    with sqlite3.connect(DB_FILE) as conn:
        conn.row_factory = sqlite3.Row
        c = conn.cursor()
        if plate:
            c.execute("SELECT * FROM fuel_records WHERE plate=? ORDER BY record_date ASC", (plate,))
        else:
            c.execute("SELECT * FROM fuel_records ORDER BY record_date ASC")
        rows = c.fetchall()
        return [dict(r) for r in rows]

def insert_record(plate, record_date, station, fuel_type, price, odometer, liters, odo_diff, cost, fuel_eff, fuel_additives):
    with sqlite3.connect(DB_FILE) as conn:
        c = conn.cursor()
        c.execute("""
            INSERT INTO fuel_records
            (plate, record_date, station, fuel, price, odo_reading, volume, distance, cost, fuel_efficiency, fuel_additives)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """, (plate, record_date, station, fuel_type, price, odometer, liters, odo_diff, cost, fuel_eff, fuel_additives))
        conn.commit()

@app.route("/", methods=["GET", "POST"])
def index():
    stations = get_stations()
    if request.method == "POST":
        plate = request.form["plate"]
        record_date = datetime.now().strftime('%Y-%m-%d %H:%M')
        station = request.form["station"]
        fuel_type = request.form["fuel_type"]
        fuel_additives = request.form["fuel_additives"]
        price = float(request.form["price"])
        odometer = float(request.form["odometer"])
        liters = float(request.form["liters"])
        cost = liters * price

        records = get_records(plate)
        max_odo = max((float(r["odo_reading"]) for r in records), default=0.0)
        odo_diff = odometer - max_odo
        fuel_eff = round(odo_diff / liters, 2) if liters > 0 else 0.0

        insert_record(plate, record_date, station, fuel_type, price, odometer, liters, odo_diff, cost, fuel_eff, fuel_additives)

        return redirect(url_for("index", plate=plate))

    plate = request.args.get("plate")
    records = get_records(plate)

    return render_template("index.html",
                           plates=CAR_PLATES,
                           stations=stations,
                           records=records,
                           selected_plate=plate)

@app.route("/last_record/<path:plate>")
def last_record(plate):
    last_date = None
    last_odo = None
    last_add_odo = None
    dates = []
    effs = []
    ododiffs = []

    with sqlite3.connect(DB_FILE) as conn:
        conn.row_factory = sqlite3.Row
        c = conn.cursor()

        c.execute(
            "SELECT * FROM fuel_records WHERE plate=? ORDER BY record_date DESC LIMIT 1",
            (plate,)
        )
        latest = c.fetchone()
        if latest:
            last_date = latest["record_date"]
            last_odo = latest["odo_reading"]

        c.execute(
            "SELECT * FROM fuel_records WHERE plate=? AND fuel_additives='有' ORDER BY record_date DESC LIMIT 1",
            (plate,)
        )
        latest_add = c.fetchone()
        if latest_add:
            last_add_odo = latest_add["odo_reading"]
        else:
            last_add_odo = '0'

        c.execute(
            "SELECT record_date, fuel_efficiency, distance FROM fuel_records WHERE plate=? ORDER BY record_date ASC",
            (plate,)
        )
        rows = c.fetchall()
        for r in rows:
            dates.append(r["record_date"].split(" ")[0])
            effs.append(r["fuel_efficiency"])
            ododiffs.append(r["distance"])

    return jsonify({
        "date": last_date,
        "odometer": last_odo,
        "dates": dates,
        "effs": effs,
        "ododiffs": ododiffs,
        "last_add_odo": last_add_odo
    })

@app.route('/apple-touch-icon.png')
def apple_touch_icon():
    return send_from_directory('static', 'apple-touch-icon.png')

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=xxxx)

前端使用Bootstrap 5排版並搭配Plotly.js繪圖。表單可選擇車牌、加油站、油品、輸入單價、里程、加油量與是否添加油精,並檢查里程不得小於上次紀錄及提醒超過 1000 公里未使用油精。下方表格動態顯示該車的加油歷史,圖表則同時呈現每次加油距離與油耗變化,方便使用者即時管理與分析油耗數據。

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <title>車輛油耗紀錄系統</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        table {
            font-size: 0.9rem;
        }
        @media (max-width: 768px) {
            table {
                font-size: 0.8rem;
            }
        }
    </style>
</head>
<body class="p-3">
<div class="container">
    <h2 class="mb-4">車輛油耗紀錄系統</h2>

    <form method="POST" class="mb-4 d-flex flex-column gap-3">
        <div>
            <label class="form-label">車牌</label>
            <select id="plate" name="plate" class="form-select" required>
                <option value="">請選擇車牌</option>
                {% for p in plates %}
                    <option value="{{ p }}" {% if p == selected_plate %}selected{% endif %}>{{ p }}</option>
                {% endfor %}
            </select>
        </div>

        <div id="last-info" class="mt-2" style="display:none;">
            <span class="fw-bold text-primary">上次加油日期為</span>
            <span id="last-date" class="fw-bold text-danger"></span>
            <span class="fw-bold text-primary">,里程數為</span>
            <span id="last-odo" class="fw-bold text-danger"></span>
            <span class="fw-bold text-primary">公里。</span>
            <br>
            <span class="fw-bold text-primary">上次使用油精的里程數為</span>
            <span id="last-add-odo" class="fw-bold text-danger"></span>
            <span class="fw-bold text-primary">公里。</span>
        </div>

        <div>
            <label class="form-label">加油站</label>
            <select id="station" name="station" class="form-select" required>
                <option value="">請選擇加油站</option>
                {% for s in stations.keys() %}
                    <option value="{{s}}">{{s}}</option>
                {% endfor %}
            </select>
        </div>

        <div>
            <label class="form-label">油品</label>
            <select id="fuel_type" name="fuel_type" class="form-select" required>
                <option value="">請選擇油品</option>
            </select>
        </div>

        <div>
            <label class="form-label">油品單價 (元)</label>
            <input type="number" step="0.1" name="price" id="price" class="form-control" required>
        </div>

        <div>
            <label class="form-label">里程 (公里)</label>
            <input type="number" step="0.1" name="odometer" id="odometer" class="form-control" required>
            <div id="odo-error" class="text-danger mt-1" style="display:none;">里程不得小於上次加油里程!</div>
            <div id="additive-warning" class="alert alert-warning mt-2 py-2 px-3" style="display:none;">
                ⚠ 建議使用油精,因為距離上次使用已超過 1000 公里!
            </div>
        </div>

        <div>
            <label class="form-label">加油量 (公升)</label>
            <input type="number" step="0.01" name="liters" class="form-control" required>
        </div>

        <div>
            <label class="form-label">使用油精</label>
            <select id="fuel_additives" name="fuel_additives" class="form-select" required>
                <option value="無">無</option>
                <option value="有">有</option>
            </select>
        </div>

        <div>
            <button type="submit" class="btn btn-primary">新增紀錄</button>
        </div>
    </form>

    <div id="chart-container" class="mb-3"></div>

    <div id="table-container">
        <table class="table table-bordered table-striped">
            <thead>
                <tr>
                    <th>日期</th>
                    <th>油品</th>
                    <th>里程</th>
                    <th>距離</th>
                    <th>油量</th>
                    <th>油耗</th>
                    <th>油精</th>
                </tr>
            </thead>
            <tbody id="table-body">
            </tbody>
        </table>
    </div>
</div>

<script src="https://cdn.plot.ly/plotly-2.29.1.min.js"></script>
<script>
    const stationsData = {{ stations | tojson }};
    const stationSelect = document.getElementById("station");
    const fuelSelect = document.getElementById("fuel_type");
    const priceInput = document.getElementById("price");
    const plateSelect = document.getElementById("plate");
    const tableBody = document.getElementById("table-body");
    const allRecords = {{ records | tojson }};

    const odometerInput = document.getElementById("odometer");
    const odoErrorDiv = document.getElementById("odo-error");
    const additiveWarning = document.getElementById("additive-warning");
    const lastOdoSpan = document.getElementById("last-odo");
    const lastAddOdoSpan = document.getElementById("last-add-odo");

    stationSelect.addEventListener("change", function() {
        const fuels = stationsData[this.value] || {};
        fuelSelect.innerHTML = "<option value=''>請選擇油品</option>";
        for (const f in fuels) {
            const opt = document.createElement("option");
            opt.value = f;
            opt.textContent = f;
            fuelSelect.appendChild(opt);
        }
        priceInput.value = "";
    });

    fuelSelect.addEventListener("change", function() {
        const station = stationSelect.value;
        const fuel = this.value;
        if (station && fuel && stationsData[station][fuel] !== undefined) {
            priceInput.value = stationsData[station][fuel];
        } else {
            priceInput.value = "";
        }
    });

    function renderTable(records) {
        console.log(records)
        tableBody.innerHTML = "";
        if (records.length > 0) {
            records.forEach(r => {
                const d = new Date(r["record_date"]);
                const dateStr = `${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
                const odo = Math.round(r["odo_reading"]);
                const ododiff = Math.round(r["distance"]);
                const fuel = parseFloat(r["volume"]).toFixed(1);
                const eff = parseFloat(r["fuel_efficiency"]).toFixed(1);
                const stationFuel = (r["station"] ? r["station"].charAt(0) : "") + (r["fuel"] || "");
                const add = r["fuel_additives"];
                const tr = document.createElement("tr");
                tr.innerHTML = `
                    <td>${dateStr}</td>
                    <td>${stationFuel}</td>
                    <td>${odo}</td>
                    <td>${ododiff}</td>
                    <td>${fuel}</td>
                    <td>${eff}</td>
                    <td>${add}</td>
                `;
                tableBody.appendChild(tr);
            });
        }
    }

    function getLastOdoValue() {
        const val = lastOdoSpan.textContent;
        return val ? parseFloat(val) : 0;
    }

    function getLastAddOdoValue() {
        const val = lastAddOdoSpan.textContent;
        return val ? parseFloat(val) : null;
    }

    odometerInput.addEventListener("input", function() {
        const lastOdo = getLastOdoValue();
        const lastAddOdo = getLastAddOdoValue();
        const current = parseFloat(this.value);

        if (!isNaN(current) && current <= lastOdo) {
            odoErrorDiv.style.display = "block";
            this.setCustomValidity("里程不得小於上次加油里程!");
        } else {
            odoErrorDiv.style.display = "none";
            this.setCustomValidity("");
        }

        if (!isNaN(current) && lastAddOdo !== null && (current - lastAddOdo >= 1000)) {
            additiveWarning.style.display = "block";
        } else {
            additiveWarning.style.display = "none";
        }
    });

    document.querySelector("form").addEventListener("submit", function(e) {
        const lastOdo = getLastOdoValue();
        const current = parseFloat(odometerInput.value);
        if (!isNaN(current) && current <= lastOdo) {
            alert("里程不得小於上次加油里程!");
            e.preventDefault();
        }
    });

    function renderChart(selectedPlate) {
        const chartContainer = document.getElementById("chart-container");
        chartContainer.innerHTML = "";
        if (!selectedPlate) return;

        fetch(`/last_record/${encodeURIComponent(selectedPlate)}`)
            .then(res => res.json())
            .then(data => {
                const lastInfoDiv = document.getElementById("last-info");
                const lastDateSpan = document.getElementById("last-date");

                if (data.date && data.odometer) {
                    lastDateSpan.textContent = data.date;
                    lastOdoSpan.textContent = data.odometer;
                    lastInfoDiv.style.display = "block";
                } else {
                    lastInfoDiv.style.display = "none";
                }

                if (data.last_add_odo) {
                    lastAddOdoSpan.textContent = data.last_add_odo;
                } else {
                    lastAddOdoSpan.textContent = "";
                }

                if (!data.dates || data.dates.length === 0) return;

                const dates = data.dates;
                const effs = data.effs;
                const ododiffs = data.ododiffs;
                const barCount = dates.length;
                const bargap = Math.min(1, Math.max(0.05, 1 - (barCount * 0.04)));

                Plotly.newPlot(chartContainer, [
                    {
                        x: dates,
                        y: ododiffs,
                        name: "距離(km)",
                        type: "bar",
                        yaxis: "y2",
                        marker: { color: 'rgba(135,206,250,0.5)' }
                    },
                    {
                        x: dates,
                        y: effs,
                        name: "油耗(km/L)",
                        type: "scatter",
                        mode: "lines+markers",
                        yaxis: "y1",
                        marker: { color: 'rgba(205,92,92,1)', size: 12 },
                        line: { color: 'rgba(205,92,92,1)', width: 2 }
                    }
                ], {
                    yaxis: { title: "油耗(km/L)" },
                    yaxis2: { title: "距離(km)", overlaying: "y", side: "right" },
                    xaxis: { title: "日期", tickformat: "%m-%d" },
                    height: 400,
                    margin: { l:50, r:50, t:70, b:50 },
                    legend: { x:0, y:1.1, orientation:"h" },
                    bargap: bargap
                });
            });
    }

    const initialPlate = "{{ selected_plate }}";
    if (initialPlate) {
        const filtered = allRecords.filter(r => r["plate"] === initialPlate);
        renderTable(filtered);
        renderChart(initialPlate);
    } else {
        renderTable([]);
    }

    plateSelect.addEventListener("change", function() {
        const selectedPlate = this.value;
        const filtered = allRecords.filter(r => r["plate"] === selectedPlate);
        renderTable(filtered);
        renderChart(selectedPlate);
    });
</script>

</body>
</html>

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

返回頂端