你說:
Any free Proxi (Crowdsourced Maps) demo?
It depends a bit on what you mean by “Proxi (Crowdsourced Maps)” — e.g. whether you meant Proxi (a platform) or just any crowdsourced map / map‐proxy demo. Here are a few free demos / open tools you can try:
Proxi — Crowdsource Maps
Proxi offers a free version (limited to 25 points per map) and you can try a demo / start creating a crowdsourced map. proxi.co+1
You can enable “Let Viewers Suggest Points” to allow crowdsourcing. learn.proxi.co
Shareloc (Open Source)
A free open map API with an online demo at apps.meggsimum.de/shareloc. GitHub
You can set up your own instance, or use their demo to test features.
crowdsourced‑map (no backend server)
A simple demo that lets anyone add pins to a public map using a Google Form as backend. GitHub
This is lightweight and good for small experiments.
MapProxy Demos
If by “proxy” you meant tile / map proxying, the MapProxy project has live demos. maps.heigit.org+3proxy.petrimap.net+3osm.citynomadi.com+3
(MapProxy is generally a tiling / caching / proxying service for map tiles.)
If you tell me more precisely what you meant by "Proxi (Crowdsourced Maps)" — e.g. whether you meant that specific product, or just “a crowdsourced map with proxy / caching features” — I can point you to the best demo for your use case (even one you can host yourself). Do you want me to send you a live working demo link you can interact with (and try adding points)?
這是一個很好的專案想法;下面我先列出整體設計思路 + 可採用的開源方案 + 示範/樣板 + 技術堆疊建議,你可以從中選一條路線來實作。
你的核心需求是:
在地圖上點選一個位置,建立一個 marker(地標點)
上傳照片 + 輸入文字描述
多人可以操作,資料集中儲存
管理者/指揮者可以看到所有標記,進行調度或分析
延伸可能需求還有:
標記分類(如災情、道路中斷、水淹、電力中斷等)
標記核可流程(先給編輯者審核再上線)
標記搜尋 / 篩選 / 分層顯示
標記時間維度(看到哪一天的資料)
權限管理(某些人只能看,某些人可以編輯/刪除)
圖片大小限制 / 壓縮 / 儲存管理
地圖底圖樣式、自定義圖資來源
為了避免從零開始做太多東西,建議採用既有的開源框架或樣板,改造或擴充。
以下是一些可以拿來改造或直接使用的開源/免費專案:
專案 | 特點 | 優點 / 限制 | 備註 |
---|---|---|---|
map‑with‑markers (Flask + Leaflet) | 簡單的示範專案,可讓使用者新增標記 + 上傳媒體檔案。 GitHub | 架構輕量,適合用作入門範本;上傳功能已經內建 | 可拿來做原型,之後擴充權限、分類等功能 |
crowdsourced‑map | 用 Javascript/Node.js 架設的交互地圖,讓用戶提交建議標記 GitHub | 社群專案,已實作前端 + 標記功能 | 需加照片上傳管理功能 |
Open User Map (WordPress 插件 / 開源專案 “open-user-map”) | 讓使用者從前台新增地點標記,支援上傳圖片、各種欄位等功能 Open User Map WordPress Plugin+1 | 如果你本身用 WordPress 作為網站平台,這是快速部署方案 | 若非 WordPress 平台,則可參考它的設計思路 |
uMap (OpenStreetMap 的擴充工具) | uMap 是 OSM 社群推出的工具,使用者可以建立自訂地圖 & 標記 | 使用方便、可匯入 / 匯出 GeoJSON / CSV 等格式 | 自己要部署伺服器版本以便控管存取權限 |
自組前端 + 地圖庫 + 後端 API | 自己寫前端 + 使用地圖 Javascript 函式庫(如 Leaflet.js、OpenLayers) + 後端儲存 API(例如 Node.js、Python Flask / Django、Go、PHP) | 彈性最高,能完全符合你的需求 | 需要做完整的前後端設計與安全考量 |
下面是一條可能的技術路線(比較常見、社群支援也比較多):
前端
– 地圖展示:Leaflet.js(輕量、簡單好用)或 OpenLayers
– UI 框架:React / Vue / plain JS + HTML/CSS
– 圖片上傳表單 + 點擊地圖新增標記功能
後端 / API
– 使用 Node.js (express)、Python (Flask / Django)、或其他你熟悉的語言
– API 端點:新增標記(含文字 + 圖片)、取得所有標記、編輯刪除標記、授權驗證
– 儲存:資料庫(MySQL / PostgreSQL / SQLite / MongoDB)儲存標記資料;圖檔儲存在檔案系統或雲端儲存(如 AWS S3 / 本機uploads 資料夾)
– 圖片處理:上傳時檢查檔案大小 / 類型,可能做壓縮或重製尺寸
認證 / 權限
– 若多使用者環境,應設計使用者登入 / 權限控制
– 審核機制:普通用戶上傳標記先進入待核可狀態,管理員審核後才顯示
地圖圖資 / 底圖
– 可使用 OpenStreetMap 或其他免費地圖圖塊供應商(如 Mapbox 的免費方案、或 Maptiler、Carto 等)
– 若人流多或流量大,需考慮圖塊快取或代理服務
篩選 / 搜索 / 分層顯示
– 標記可帶分類屬性,在前端可切換哪些類別要顯示
– 提供搜尋/關鍵字過濾
時間線 / 歷史資料(若需要)
– 每個標記可記錄時間戳,前端可能提供 slider 或時間選擇器來過濾
以下是一個從零到有的簡易示範流程(假設你用 Python Flask + Leaflet):
建立專案、安裝 Flask、設定基本 API
前端建立 Leaflet 地圖,載入初始標記(呼叫 GET /markers
API)
在地圖點擊事件中打開一個上傳對話窗(讓使用者選定上傳照片 + 填文字)
提交資料到後端 API POST /markers
,後端處理儲存資料與圖片,回傳新標記資訊
前端收到回傳後,在地圖上加上該標記(marker + popup 顯示圖片與文字)
管理介面:列出所有標記,可審核 / 編輯 / 刪除
部署上線,搭配 HTTPS / 安全檢查 / 圖檔大小限制
這樣一來,即可達到你想要的「多人在地圖上點位置 + 上傳照片 + 輸入文字」功能。
如果你願意嘗試一個快速 prototype(樣板):
先拿 map-with-markers 專案:克隆下來,跑本地環境,看看上傳 + 顯示標記的流程怎麼實作 GitHub
或者拿 crowdsourced-map 專案,看看它如何管理使用者提交的標記 GitHub
在這些樣板上加入你專案需要的欄位(如:標記類別、時間戳、審核狀態)
你也可以先做一個簡易的 mock-up(雛形網頁)讓同仁測試,收集意見,再漸進開發。
如果你願意,我可以幫你做一個最簡易可運行的樣板原始碼(Flask + Leaflet)給你,你可以自己部署在伺服器上。要嗎?
下面我幫你做一個最簡易可運行的範本:用 Flask + Leaflet.js,可以在地圖上點擊新增標記(marker),並附帶文字與圖片上傳;後端儲存資料(純示範用途、沒做完全的安全性與驗證)
你可以拿這個範本為基礎改造、擴充。
arduinoflask_map_app/
│
├── app.py
├── templates/
│ └── index.html
└── static/
├── uploads/ ← 圖片上傳儲存目錄(Web 可瀏覽或保護設定看你的需求)
├── css/
│ └── style.css
└── js/
└── map.js
下面是各檔案的內容範本:
app.py
pythonimport os
from flask import Flask, render_template, request, jsonify, send_from_directory
from werkzeug.utils import secure_filename
app = Flask(__name__)
# 圖片上傳設定
UPLOAD_FOLDER = os.path.join(app.root_path, 'static', 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
# 限制允許的副檔名
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# 暫存標記資料 (示範用,重啟會消失)
# 真實應用應該用資料庫
markers = [] # 每個 marker 是 dict:{ "lat": ..., "lng": ..., "text": ..., "image_url": ... }
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@app.route('/')
def index():
return render_template('index.html')
@app.route('/markers', methods=['GET'])
def get_markers():
return jsonify(markers)
@app.route('/markers', methods=['POST'])
def post_marker():
lat = request.form.get('lat')
lng = request.form.get('lng')
text = request.form.get('text', "")
file = request.files.get('image')
if not lat or not lng:
return jsonify({"error": "no lat/lng"}), 400
image_url = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
# 這裡假設你的站點是跟 static 路徑對應,image_url 用相對 static 的URL
image_url = f"/static/uploads/{filename}"
marker = {
"lat": float(lat),
"lng": float(lng),
"text": text,
"image_url": image_url
}
markers.append(marker)
return jsonify(marker), 201
if __name__ == '__main__':
app.run(debug=True)
templates/index.html
html<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>災情地圖示範</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha512‑…"
crossorigin=""/>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<h2>災情地圖(點擊地圖可以新增標記)</h2>
<div id="map" style="height: 600px;"></div>
<!-- 上傳表單對話框 -->
<div id="formPopup" style="display: none; position: absolute; background: white; padding: 10px; border: 1px solid #666;">
<form id="markerForm">
<input type="hidden" id="lat" name="lat">
<input type="hidden" id="lng" name="lng">
<div>
<label>文字描述:</label><br>
<textarea name="text" id="text" rows="3" cols="30"></textarea>
</div>
<div>
<label>上傳圖片:</label>
<input type="file" name="image" id="imageFile" accept="image/*">
</div>
<div style="margin-top: 5px;">
<button type="submit">送出</button>
<button type="button" id="cancelBtn">取消</button>
</div>
</form>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha512‑…"
crossorigin=""></script>
<script src="{{ url_for('static', filename='js/map.js') }}"></script>
</body>
</html>
static/js/map.js
javascript// 初始化地圖
var map = L.map('map').setView([23.5, 121.5], 8);
// 使用 OpenStreetMap 圖磚層
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// 載入現有標記
function loadMarkers() {
fetch('/markers')
.then(res => res.json())
.then(data => {
data.forEach(m => {
addMarkerToMap(m);
});
});
}
// 在地圖上加入標記與 popup
function addMarkerToMap(m) {
var marker = L.marker([m.lat, m.lng]).addTo(map);
var popupHtml = `<div>${m.text || ''}</div>`;
if (m.image_url) {
popupHtml += `<div><img src="${m.image_url}" style="max-width:200px;"></div>`;
}
marker.bindPopup(popupHtml);
}
// 當地圖被點擊時,顯示填寫表單位置
map.on('click', function(e) {
var px = map.latLngToContainerPoint(e.latlng);
var formDiv = document.getElementById('formPopup');
formDiv.style.left = px.x + 'px';
formDiv.style.top = px.y + 'px';
formDiv.style.display = 'block';
// 填入隱藏欄位
document.getElementById('lat').value = e.latlng.lat;
document.getElementById('lng').value = e.latlng.lng;
});
// 表單提交處理
document.getElementById('markerForm').addEventListener('submit', function(evt) {
evt.preventDefault();
var form = evt.target;
var formData = new FormData(form);
fetch('/markers', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(m => {
addMarkerToMap(m);
document.getElementById('formPopup').style.display = 'none';
form.reset();
})
.catch(err => {
console.error('上傳失敗', err);
alert('上傳失敗');
});
});
// 取消按鈕
document.getElementById('cancelBtn').addEventListener('click', function() {
document.getElementById('formPopup').style.display = 'none';
});
// 初次載入標記
loadMarkers();
static/css/style.css
css/* 可以加些簡單樣式 */
body {
font-family: Arial, sans-serif;
}
#formPopup {
z-index: 1000;
}
安裝必要套件
bashpip install flask
在專案目錄下執行
bashpython app.py
打開瀏覽器到 http://127.0.0.1:5000/
你會看到地圖,點擊地圖任一處會跳出表單讓你輸入文字與上傳圖片。提交後,該點會以標記出現,popup 裡會顯示文字與圖片。
每次重啟 Flask 程式,之前的標記都會消失,因為這範本是存在記憶體(markers
list)。在真實環境中,你要改成儲存在資料庫(例如 MySQL / PostgreSQL / SQLite / MongoDB),以便長期保存。
資料庫整合:將 markers
改為資料庫模型,儲存更多欄位(分類、使用者、時間戳、審核狀態等)
使用者認證與權限:讓某些人只能看、某些人可以新增 / 編輯 / 刪除
圖片檔案命名衝突 / 安全檢查
檔案大小限制 / 壓縮 / 縮圖
標記分類 / 分層:不同類型的標記可以用不同顏色或圖示顯示,並可勾選開關
標記搜尋 / 過濾 / 時間線功能
地圖底圖/圖磚服務:若大量使用者,可能要考慮自己快取圖磚、或使用 Mapbox / MapTiler / Tileserver 等
即時更新 / WebSocket:若多人同時操作,你可能要做 WebSocket 或 Server-Sent Events(SSE),讓其他使用者頁面即時看到新標記
安全性:檢查 XSS、上傳檔案驗證、不信任輸入等
如果你願意,我可以幫你把這個範本做成可部屬到雲端(像是 Heroku / AWS / GCP / Azure / Vercel)的版本,並提供完整原始碼給你,你要嗎?
好的,下面我把範本程式改寫一次,改成使用 SQLite 資料庫 儲存標記資料(包括文字、圖片路徑),這樣重啟程式後仍可讀取之前新增的資料。圖片實體檔案仍存在檔案系統裡(static/uploads/
)或你指定的目錄。
我會給你完整的 app.py
、資料表初始化、以及前端部分的調整。你可以把它接回之前那個範本專案目錄下。
這裡我們會用到 flask
(已用過)與 sqlite3
(Python 內建)。若你要用 ORM,也可以用 Flask‑SQLAlchemy
,但我這裡先用原生的 sqlite3
,比較直接明白。
bashpip install flask
在專案目錄中,你可以新增一個檔案 schema.sql
,內容如下:
sql-- schema.sql
DROP TABLE IF EXISTS markers;
CREATE TABLE markers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lng REAL NOT NULL,
text TEXT,
image_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
這個 schema 定義了一個 markers
表格,欄位有:
id
: 主鍵
lat, lng
: 標記的經緯度
text
: 使用者輸入的文字
image_path
: 圖檔在伺服器上的相對路徑或 URL(如 static/uploads/xxx.jpg
)
created_at
: 建立時間
接著在 app.py
中,我們提供一個初始化資料庫的函式 init_db()
,用來執行這個 schema。
app.py
下面是完整改好的 app.py
,你可以把之前的版本替換掉:
pythonimport os
import sqlite3
from flask import Flask, render_template, request, jsonify, g
from werkzeug.utils import secure_filename
app = Flask(__name__)
# 設定上傳資料夾
UPLOAD_FOLDER = os.path.join(app.root_path, 'static', 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
# 允許的圖片副檔名
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# SQLite 資料庫檔案路徑(與這個 app.py 同層目錄下,檔案名稱 markers.db)
DATABASE = os.path.join(app.root_path, 'markers.db')
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_db():
"""
取得目前 request 上下文下的資料庫連線,若還沒開,就開一個。
"""
db = getattr(g, '_database', None)
if db is None:
# 連線時加上 row factory,讓 fetch 回來的是 dict 形式比較好用
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
g._database = conn
return g._database
@app.teardown_appcontext
def close_connection(exception):
"""
在 request 結束後關閉資料庫連線(如果有開的話)
"""
db = getattr(g, '_database', None)
if db is not None:
db.close()
def init_db():
"""
用 schema.sql 初始化資料庫(建立表格)
"""
db = get_db()
with app.open_resource('schema.sql', mode='r') as f:
db.cursor().executescript(f.read())
db.commit()
@app.route('/initdb')
def initdb_route():
"""
透過 HTTP 路徑來初始化資料庫(只用於開發時期)
"""
init_db()
return "Database initialized!"
@app.route('/')
def index():
return render_template('index.html')
@app.route('/markers', methods=['GET'])
def get_markers():
db = get_db()
cur = db.execute("SELECT id, lat, lng, text, image_path, created_at FROM markers")
rows = cur.fetchall()
# 轉為 list of dict
markers = []
for r in rows:
markers.append({
"id": r["id"],
"lat": r["lat"],
"lng": r["lng"],
"text": r["text"],
"image_url": r["image_path"], # 這是相對路徑或 URL
"created_at": r["created_at"]
})
return jsonify(markers)
@app.route('/markers', methods=['POST'])
def post_marker():
lat = request.form.get('lat')
lng = request.form.get('lng')
text = request.form.get('text', "")
file = request.files.get('image')
if lat is None or lng is None:
return jsonify({"error": "lat/lng missing"}), 400
# 處理圖片上傳
image_path = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
# 為避免檔名衝突,可以加上時間戳或 random 字串
# 這裡簡單示範:
name, ext = os.path.splitext(filename)
filename = f"{name}_{int(sqlite3.time.time())}{ext}"
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
# image_path 是供前端存取的相對 URL
image_path = f"/static/uploads/{filename}"
# 將資料寫入 SQLite
db = get_db()
cur = db.cursor()
cur.execute(
"INSERT INTO markers (lat, lng, text, image_path) VALUES (?, ?, ?, ?)",
(float(lat), float(lng), text, image_path)
)
db.commit()
marker_id = cur.lastrowid
# 回傳剛新增的那筆資料
new_marker = {
"id": marker_id,
"lat": float(lat),
"lng": float(lng),
"text": text,
"image_url": image_path
}
return jsonify(new_marker), 201
if __name__ == '__main__':
# 若 markers.db 不存在,啟動時可以自動初始化
if not os.path.exists(DATABASE):
with app.app_context():
init_db()
app.run(debug=True)
重點說明:
get_db()
函式會檢查 g._database
是否已存在連線,若沒有,建立一條連線並設定 row_factory = sqlite3.Row
,方便用 r["欄位名"]
存取欄位。這樣在 get_markers()
中就可以讀取那些欄位比較乾淨。
teardown_appcontext
裝飾器會在每一次 request 完後自動關閉連線。這是 Flask 官網推薦的做法。 flask.palletsprojects.com
新增一條 route /initdb
,方便在開發階段手動重建資料庫(清空 + 建表)。你可以在開發時打開瀏覽器去 http://localhost:5000/initdb
,就會執行 schema.sql
。
啟動 app.py
時,若 markers.db
不存在,會自動呼叫 init_db()
,建立資料庫與表格。
圖片檔案仍是存在 static/uploads/
,在資料庫中只記錄圖檔的相對 URL 或路徑 image_path
。這是普遍做法:把大檔像圖片存檔案系統、資料庫只存路徑。這也跟許多教學一致。 geek-docs.com
get_markers()
會把所有標記資料(包含圖片 URL)以 JSON 回傳給前端。
其實幾乎不需要大幅改動。之前那個版本就可以繼續用,只要確保:
在 fetch('/markers')
那邊可以正確拿到 JSON(現在 JSON 裡每條記錄多了 id
、created_at
、image_url
)
在 addMarkerToMap(m)
中,m.image_url
若為 null
或 None
要檢查,避免前端崩潰
這邊稍微給你修改版本的前端 JavaScript 片段(map.js
)作為參考:
javascriptfunction addMarkerToMap(m) {
var marker = L.marker([m.lat, m.lng]).addTo(map);
var popupHtml = `<div>${m.text || ""}</div>`;
if (m.image_url) {
popupHtml += `<div><img src="${m.image_url}" style="max-width:200px;"></div>`;
}
popupHtml += `<div style="font-size: 0.8em; color: #666;">ID: ${m.id}</div>`;
marker.bindPopup(popupHtml);
}
其他部分(點擊地圖跳出表單、提交上傳)與先前版本相同即可。
把 schema.sql
、改好的 app.py
、舊的 templates/index.html
和 static/js/map.js
、static/css/style.css
都放好。
在終端/命令列執行:
bashpython app.py
若第一次啟動,markers.db
檔案會被自動建立。你也可打 /initdb
強制重建(會清除舊資料)。
用瀏覽器開啟 http://127.0.0.1:5000/
,進行地圖點擊、上傳照片、填寫文字等操作。
重啟程式(Ctrl+C 再 python app.py
),地圖上之前標記應該依然存在,因為資料存在 markers.db
,圖片檔案仍存在 static/uploads/
。
圖片檔名衝突處理:我範本中用簡單方法在檔名前加上時間戳來避免衝突。你可以改進成 UUID、使用雜湊、或加入使用者 ID 等來更穩妥。
安全性檢查:檔案類型、檔案大小要限制;避免惡意上傳。
圖片儲存空間管理:若檔案很多,可能要考慮刪除舊照片、做壓縮、或存到雲端物件儲存(如 S3)。
索引與查詢效率:若資料很多,SQL 查詢可能慢,未來可能要加入索引(例如在 lat,lng
欄位上做地理索引或使用地理資料庫)。
權限管理 / 審核機制:讓部分用戶只能查看、不能上傳、或上傳後需審核才能顯示。
同時使用者同步:若多人同時上傳標記,前端可能要做 自動重載 或用 WebSocket / SSE 來即時更新其他使用者看到的標記。
備份與資料遷移:若未來要從 SQLite 換成 MySQL / PostgreSQL,要有資料遷移計畫。
如果你願意,我也可以幫你把這個範本打包成一個 GitHub 專案、並幫你部署到某個雲端(如 Heroku、Render、Vercel、或自己 VPS)讓你可以直接測試。要嗎?
很好,這是個常見且有用的需求——在一個已存在的 Marker 上「追加/編輯」文字與照片,以便追蹤歷史與進度。下面我提供一個設計方向與程式碼範例,展示如何讓使用者對既有標記進行更新、並在資料庫中保留歷史紀錄。
我們要把標記(marker)當成一個物件,每個物件除了最初的「建立」資料外,還可以有一個或多個「更新紀錄」:
主表 markers
:存放標記的基本資料(經緯度、建立時間、目前文字/圖片或最初版本)
更新表 marker_updates
(或稱歷史紀錄表):記錄對該標記每次的更新(時間、文字、照片、更新者等)
或者把 markers
表設計為允許多張照片、多條文字(儲存在 JSON 或另一個子表)
選這兩種設計之一:
方法 | 優點 | 缺點 |
---|---|---|
子表 (updates table) | 每次更新都保有歷史紀錄、清楚追蹤;主表可以保留最新狀態 | 要設計多表 JOIN、前端要取出與展示更新歷史 |
在主表中儲存陣列 / JSON(例如圖片陣列、留言串) | 實作相對簡單(單表),前端取資料容易 | 資料庫查詢或更新比較麻煩;資料可能無法有效索引;歷史不可拆出查詢效率低 |
我建議用「子表」方式,也就是:
markers
:主表,保留不變的資訊(如經緯度)、以及最新的狀態(或不保最新狀態,僅用 updates 查最新)
marker_updates
:每次使用者對該 marker 做的變更(一筆或多筆),包含文字、圖片路徑、時間戳、操作人員等
這樣就能做到「從已存在的 marker 上新增文字或圖片」的功能,同時保留歷史紀錄。
假設我們現在的主表 markers
已經有:
sqlCREATE TABLE markers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lng REAL NOT NULL,
text TEXT,
image_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
我們可以新增一張更新紀錄表:
sqlCREATE TABLE marker_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
marker_id INTEGER NOT NULL,
text TEXT,
image_path TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (marker_id) REFERENCES markers(id)
);
marker_id
是外鍵,指出這筆更新屬於哪個 marker
text
, image_path
: 這次更新新增的文字與圖片(可為空)
updated_at
: 更新時間
此外,你可以改動主表 markers
的 text
與 image_path
欄位用途:可以保最新狀態,也可以只保最初狀態、讓更新紀錄表去讀取最新狀態。
下面我在你原本那個 app.py
基礎上,展示如何加上「更新 marker」的 route。假設你已經有 markers
主表,且你已新增了 marker_updates
表。
python# 假設已有 get_db()、init_db()、markers 表的設計如前
@app.route('/markers/<int:marker_id>/updates', methods=['POST'])
def post_marker_update(marker_id):
"""
對已存在的 marker(透過 id)做一次更新:可加文字、可加照片(或兩者皆有)。
"""
# 先檢查這個 marker 是否存在
db = get_db()
cur = db.execute("SELECT id FROM markers WHERE id = ?", (marker_id,))
row = cur.fetchone()
if row is None:
return jsonify({"error": "marker not found"}), 404
text = request.form.get('text', None)
file = request.files.get('image')
image_path = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
name, ext = os.path.splitext(filename)
filename = f"{name}_{int(sqlite3.time.time())}{ext}" # 或用 uuid
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
image_path = f"/static/uploads/{filename}"
# 如果既沒文字也沒圖片,這次更新無效
if (text is None or text.strip() == "") and image_path is None:
return jsonify({"error": "nothing to update"}), 400
# 寫入 updates 表
cur2 = db.cursor()
cur2.execute(
"INSERT INTO marker_updates (marker_id, text, image_path) VALUES (?, ?, ?)",
(marker_id, text, image_path)
)
db.commit()
new_id = cur2.lastrowid
return jsonify({
"update_id": new_id,
"marker_id": marker_id,
"text": text,
"image_url": image_path,
}), 201
@app.route('/markers/<int:marker_id>/updates', methods=['GET'])
def get_marker_updates(marker_id):
"""
取得某個 marker 的所有更新歷史
"""
db = get_db()
# 確保 marker 存在
cur = db.execute("SELECT id FROM markers WHERE id = ?", (marker_id,))
if cur.fetchone() is None:
return jsonify({"error": "marker not found"}), 404
cur2 = db.execute(
"SELECT id, text, image_path, updated_at FROM marker_updates WHERE marker_id = ? ORDER BY updated_at ASC",
(marker_id,)
)
rows = cur2.fetchall()
updates = []
for r in rows:
updates.append({
"update_id": r["id"],
"text": r["text"],
"image_url": r["image_path"],
"updated_at": r["updated_at"]
})
return jsonify(updates)
init_db()
要加入 marker_updates
表格的建表語句在你的 schema.sql
中(或直接在初始化程式碼裡)加入:
sql-- existing markers table
DROP TABLE IF EXISTS marker_updates;
CREATE TABLE marker_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
marker_id INTEGER NOT NULL,
text TEXT,
image_path TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(marker_id) REFERENCES markers(id)
);
或是在 Python 的 init_db()
裡也加上這段 SQL。
在前端,你要為每一個標記(marker)提供一個「編輯 / 更新」入口(按鈕或鏈結),點了之後可以彈出對話框讓使用者輸入新文字、上傳新圖片。然後把資料送到新的 API /markers/{id}/updates
。
在標記的 popup(或側邊面板)中,你可以顯示該 marker 的更新歷史(呼叫 GET /markers/{id}/updates
),例如:
javascriptfunction showUpdateForm(marker, markerData) {
// markerData 有 marker 的基本資料(如 id, lat, lng, text, image_url)
// 建立一個表單或 UI 讓使用者新增更新
// 例如:
var formHtml = `
<div>
<textarea id="upd_text"> </textarea>
</div>
<div>
<input type="file" id="upd_image" accept="image/*">
</div>
<div>
<button id="upd_submit">提交更新</button>
</div>
<div id="upd_history"></div>
`;
marker.bindPopup(formHtml).openPopup();
// 取得歷史更新
fetch(`/markers/${markerData.id}/updates`)
.then(res => res.json())
.then(upds => {
var histDiv = document.getElementById('upd_history');
histDiv.innerHTML = "<h4>更新歷史:</h4>";
upds.forEach(u => {
var entry = `<div>
<div>${u.updated_at}</div>
<div>${u.text || ""}</div>
${u.image_url ? `<div><img src="${u.image_url}" style="max-width:150px;"></div>` : ""}
</div><hr>`;
histDiv.innerHTML += entry;
});
});
document.getElementById('upd_submit').addEventListener('click', function() {
var formData = new FormData();
var newText = document.getElementById('upd_text').value;
var newImage = document.getElementById('upd_image').files[0];
formData.append('text', newText);
if (newImage) {
formData.append('image', newImage);
}
fetch(`/markers/${markerData.id}/updates`, {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(ret => {
alert("更新成功");
marker.closePopup();
// 你可以選擇重新載入該 marker 的 popup 或整張地圖
});
});
}
你可以在 addMarkerToMap(m)
之類的函式裡附加一個「更新」按鈕或在 popup 底下放一個「更新」連結,呼叫上面 showUpdateForm()
。
你需要修改資料庫結構,新增一張 marker_updates
子表來保存更新歷史
在後端新增兩個 API:POST /markers/{id}/updates
(提交更新)與 GET /markers/{id}/updates
(取得歷史)
前端在已有 marker 的 popup 或 UI 中提供更新入口,讓使用者能提交新的文字與圖片
前端可同時顯示該 marker 的更新歷史,使指揮者或使用者可以追蹤進度與改變
如果你願意的話,我可以幫你把完整的範本(Flask + SQLite + 編輯更新功能)打包給你,讓你直接測試。要嗎?
好的,下面是完整的範本專案(Flask + SQLite + 編輯 / 更新 功能)。你可以把這些檔案全部放在一個資料夾裡,跑起來就可以試看看。
假設專案叫做 flask_marker_history
,目錄結構如下:
pgsqlflask_marker_history/
│
├── app.py
├── schema.sql
├── templates/
│ └── index.html
└── static/
├── uploads/ ← 圖片上傳儲存資料夾
├── css/
│ └── style.css
└── js/
└── map.js
注意:static/uploads/
資料夾要建立好給上傳圖片用。
下面是每個檔案的內容。
schema.sql
sql-- schema.sql
DROP TABLE IF EXISTS marker_updates;
DROP TABLE IF EXISTS markers;
CREATE TABLE markers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lng REAL NOT NULL,
text TEXT,
image_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE marker_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
marker_id INTEGER NOT NULL,
text TEXT,
image_path TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(marker_id) REFERENCES markers(id)
);
app.py
pythonimport os
import sqlite3
from flask import Flask, render_template, request, jsonify, g
from werkzeug.utils import secure_filename
app = Flask(__name__)
# 上傳圖片的資料夾
UPLOAD_FOLDER = os.path.join(app.root_path, 'static', 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# 資料庫檔案(在專案根目錄下)
DATABASE = os.path.join(app.root_path, 'markers.db')
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_db():
db = getattr(g, '_database', None)
if db is None:
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
g._database = conn
return g._database
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()
def init_db():
db = get_db()
with app.open_resource('schema.sql', mode='r') as f:
db.cursor().executescript(f.read())
db.commit()
@app.route('/initdb')
def initdb_route():
init_db()
return "Database initialized!"
@app.route('/')
def index():
return render_template('index.html')
@app.route('/markers', methods=['GET'])
def get_markers():
db = get_db()
cur = db.execute("SELECT id, lat, lng, text, image_path, created_at FROM markers")
rows = cur.fetchall()
out = []
for r in rows:
out.append({
"id": r["id"],
"lat": r["lat"],
"lng": r["lng"],
"text": r["text"],
"image_url": r["image_path"],
"created_at": r["created_at"]
})
return jsonify(out)
@app.route('/markers', methods=['POST'])
def post_marker():
lat = request.form.get('lat')
lng = request.form.get('lng')
text = request.form.get('text', "")
file = request.files.get('image')
if lat is None or lng is None:
return jsonify({"error": "lat or lng missing"}), 400
image_path = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
name, ext = os.path.splitext(filename)
# 可加 timestamp 或 uuid 避免衝突
filename = f"{name}_{int(sqlite3.time.time())}{ext}"
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
image_path = f"/static/uploads/{filename}"
db = get_db()
cur = db.cursor()
cur.execute(
"INSERT INTO markers (lat, lng, text, image_path) VALUES (?, ?, ?, ?)",
(float(lat), float(lng), text, image_path)
)
db.commit()
new_id = cur.lastrowid
return jsonify({
"id": new_id,
"lat": float(lat),
"lng": float(lng),
"text": text,
"image_url": image_path
}), 201
@app.route('/markers/<int:marker_id>/updates', methods=['POST'])
def post_marker_update(marker_id):
# 檢查該 marker 是否存在
db = get_db()
cur = db.execute("SELECT id FROM markers WHERE id = ?", (marker_id,))
if cur.fetchone() is None:
return jsonify({"error": "marker not found"}), 404
text = request.form.get('text', None)
file = request.files.get('image')
image_path = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
name, ext = os.path.splitext(filename)
filename = f"{name}_{int(sqlite3.time.time())}{ext}"
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
image_path = f"/static/uploads/{filename}"
# 若文字與圖片皆無,視為無效更新
if (text is None or text.strip() == "") and image_path is None:
return jsonify({"error": "nothing to update"}), 400
cur2 = db.cursor()
cur2.execute(
"INSERT INTO marker_updates (marker_id, text, image_path) VALUES (?, ?, ?)",
(marker_id, text, image_path)
)
db.commit()
new_upd_id = cur2.lastrowid
return jsonify({
"update_id": new_upd_id,
"marker_id": marker_id,
"text": text,
"image_url": image_path
}), 201
@app.route('/markers/<int:marker_id>/updates', methods=['GET'])
def get_marker_updates(marker_id):
db = get_db()
cur = db.execute("SELECT id FROM markers WHERE id = ?", (marker_id,))
if cur.fetchone() is None:
return jsonify({"error": "marker not found"}), 404
cur2 = db.execute(
"SELECT id, text, image_path, updated_at FROM marker_updates WHERE marker_id = ? ORDER BY updated_at ASC",
(marker_id,)
)
rows = cur2.fetchall()
out = []
for r in rows:
out.append({
"update_id": r["id"],
"text": r["text"],
"image_url": r["image_path"],
"updated_at": r["updated_at"]
})
return jsonify(out)
if __name__ == '__main__':
# 若資料庫不存在,啟動時建立
if not os.path.exists(DATABASE):
with app.app_context():
init_db()
app.run(debug=True)
templates/index.html
html<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>災情地圖+更新範本</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha512‑…" crossorigin=""/>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<h2>災情地圖(點擊地圖新增標記;點標記可新增更新)</h2>
<div id="map" style="height: 600px;"></div>
<!-- 新增標記用的表單 -->
<div id="formPopup" style="display: none; position: absolute; background: white; padding: 10px; border: 1px solid #666;">
<form id="markerForm">
<input type="hidden" id="lat" name="lat">
<input type="hidden" id="lng" name="lng">
<div>
<label>文字描述:</label><br>
<textarea name="text" id="text" rows="3" cols="30"></textarea>
</div>
<div>
<label>上傳圖片:</label>
<input type="file" name="image" id="imageFile" accept="image/*">
</div>
<div style="margin-top: 5px;">
<button type="submit">送出</button>
<button type="button" id="cancelBtn">取消</button>
</div>
</form>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha512‑…" crossorigin=""></script>
<script src="{{ url_for('static', filename='js/map.js') }}"></script>
</body>
</html>
static/js/map.js
javascriptvar map = L.map('map').setView([23.5, 121.5], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// 載入所有現有標記
function loadMarkers() {
fetch('/markers')
.then(res => res.json())
.then(data => {
data.forEach(m => {
addMarkerToMap(m);
});
});
}
// 把單個 marker 加到地圖上
function addMarkerToMap(m) {
var marker = L.marker([m.lat, m.lng]).addTo(map);
var popupHtml = `<div>${m.text || ""}</div>`;
if (m.image_url) {
popupHtml += `<div><img src="${m.image_url}" style="max-width:200px;"></div>`;
}
popupHtml += `<div><button onclick="openUpdateForm(${m.id})">新增 / 查看更新</button></div>`;
marker.bindPopup(popupHtml);
}
// 當地圖被點擊時,用來新增 marker
map.on('click', function(e) {
var px = map.latLngToContainerPoint(e.latlng);
var formDiv = document.getElementById('formPopup');
formDiv.style.left = px.x + 'px';
formDiv.style.top = px.y + 'px';
formDiv.style.display = 'block';
document.getElementById('lat').value = e.latlng.lat;
document.getElementById('lng').value = e.latlng.lng;
});
// 提交新 marker 表單
document.getElementById('markerForm').addEventListener('submit', function(evt) {
evt.preventDefault();
var form = evt.target;
var formData = new FormData(form);
fetch('/markers', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(m => {
addMarkerToMap(m);
document.getElementById('formPopup').style.display = 'none';
form.reset();
})
.catch(err => {
console.error('上傳失敗', err);
alert('上傳失敗');
});
});
// 取消按鈕
document.getElementById('cancelBtn').addEventListener('click', function() {
document.getElementById('formPopup').style.display = 'none';
});
// 顯示更新 / 編輯表單(在 popup 或新視窗中)
window.openUpdateForm = function(markerId) {
// 取得該 marker 的詳細資料與更新歷史
// 顯示一個簡單的對話框(使用 prompt / 自己做 UI 也可)
// 這裡簡單做一個 prompt + file input 是難做的,我改為彈出一個自訂 overlay
// 創建 overlay div
var overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.left = '0'; overlay.style.top = '0';
overlay.style.width = '100%'; overlay.style.height = '100%';
overlay.style.background = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '10000';
var box = document.createElement('div');
box.style.background = 'white';
box.style.margin = '50px';
box.style.padding = '20px';
box.style.maxHeight = '80%';
box.style.overflow = 'auto';
box.innerHTML = `
<h3>更新記錄 / 新增更新</h3>
<div id="hist"></div>
<hr>
<div>
<textarea id="upd_text" placeholder="輸入描述文字" rows="3" cols="40"></textarea>
</div>
<div>
<input type="file" id="upd_image" accept="image/*">
</div>
<div style="margin-top: 10px;">
<button id="upd_submit">送出更新</button>
<button id="upd_cancel">取消</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
// 取得更新歷史
fetch(`/markers/${markerId}/updates`)
.then(res => res.json())
.then(upds => {
var histDiv = box.querySelector('#hist');
histDiv.innerHTML = `<h4>歷史更新:</h4>`;
upds.forEach(u => {
var e = `<div>
<div style="font-size:0.9em; color:#444;">${u.updated_at}</div>
<div>${u.text || ""}</div>
${u.image_url ? `<div><img src="${u.image_url}" style="max-width:200px;"></div>` : ""}
<hr>
</div>`;
histDiv.innerHTML += e;
});
});
box.querySelector('#upd_cancel').addEventListener('click', function(){
document.body.removeChild(overlay);
});
box.querySelector('#upd_submit').addEventListener('click', function(){
var newText = box.querySelector('#upd_text').value;
var newImage = box.querySelector('#upd_image').files[0];
var fd = new FormData();
fd.append('text', newText);
if (newImage) {
fd.append('image', newImage);
}
fetch(`/markers/${markerId}/updates`, {
method: 'POST',
body: fd
})
.then(res => res.json())
.then(ret => {
alert('更新成功');
document.body.removeChild(overlay);
// 重新載入整個地圖標記(簡單方法):
// 清掉地圖再 loadMarkers
map.eachLayer(function (layer) {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
loadMarkers();
})
.catch(err => {
console.error('更新失敗', err);
alert('更新失敗');
});
});
};
// 頁面載入時先載入標記
loadMarkers();
static/css/style.css
cssbody {
font-family: Arial, sans-serif;
}
#formPopup {
z-index: 1000;
background: white;
}
在專案資料夾下 pip install flask
確定資料夾結構正確,並且 static/uploads/
存在
執行
bashpython app.py
如果是第一次執行,markers.db
檔案會被創建,並由 init_db()
建表
開瀏覽器連到 http://127.0.0.1:5000/
點地圖可新增標記(文字 + 圖片)
點標記的 “新增 / 查看更新” 按鈕可以打開更新介面:可以看到該標記的歷史更新、並新增新的文字或圖片更新
更新成功之後,overlay 關閉,地圖會重新載入標記以顯示更新後的內容
如果你願意,我可以幫你把這整個範本專案放到 GitHub,給你一個 URL 下載,也可以幫你協助部署到雲端伺服器。要嗎?
沒問題,我來幫你把範本改進,使得「舊的資訊也會保留並顯示」更完整:也就是說,對一個 marker 的初始描述 + 圖片,以及所有後續的更新紀錄,都要在畫面上可見。以下我會提供更新後的範本重點,讓你可以整合進剛剛那個專案。
主表 markers
儲存最初的描述與圖片
更新表 marker_updates
儲存後續每次更新(文字 + 圖片 + 時間)
當載入一個 marker 時,要把主表資料當作「第一筆記錄」與更新表的所有歷史併列顯示
畫面上:在 marker 的 popup 或 overlay 要有一欄給 歷史紀錄,按時間順序顯示:最初 + 每次更新
後端基本部分其實差不多已經有了,我們要稍微修改 get_markers
或前端整合時,讓前端拿到足夠資料,以顯示整個歷史。
例如,我們可以在 GET /markers
這個端點,讓它在回傳每個 marker 時,同時回傳其更新歷史(可用內部 join 或另外查一次)。或者不改 GET /markers
,前端在使用者點 marker 時,再 call GET /markers/{id}/updates
取得更新歷史。
以下示範後者:主資料與更新資料分開取得,然後前端合併顯示。
在 map.js
裡,我們要改動 addMarkerToMap(m)
使得在 popup 或 overlay 中,不只是顯示主表資料,還會顯示更新歷史。
以下是修改後的前端思路與範例:
javascriptfunction addMarkerToMap(m) {
var marker = L.marker([m.lat, m.lng]).addTo(map);
// popup content,先塞主表資料 + 一個按鈕啟動「查看 / 新增更新」
var popupHtml = `<div>
<strong>初始描述:</strong><br>
${m.text || ""}<br>`;
if (m.image_url) {
popupHtml += `<div><img src="${m.image_url}" style="max-width:200px;"></div>`;
}
popupHtml += `<hr>
<div><button onclick="openUpdateForm(${m.id}, ${m.lat}, ${m.lng})">查看 / 新增更新</button></div>
</div>`;
marker.bindPopup(popupHtml);
}
注意:openUpdateForm
我這次要改讓它帶入 marker.id, lat, lng
,方便前端知道要在哪個位置打 UI。接下來是 openUpdateForm
的修改版本:
javascriptwindow.openUpdateForm = function(markerId, lat, lng) {
// 建 overlay / box 如之前版本
// ...
// 先把主資料也拿來顯示(可 call後端的 /markers 來取得主表那筆,或在前端已有 m 為主表資料時傳進來)
// 假設前面已有 main data(m)可供使用,我們可以在 openUpdateForm 參數中也傳進該 marker 的 main text/image
// 取得更新歷史
fetch(`/markers/${markerId}/updates`)
.then(res => res.json())
.then(upds => {
var histDiv = box.querySelector('#hist');
histDiv.innerHTML = `<h4>歷史紀錄:</h4>`;
// 顯示「初始描述」
histDiv.innerHTML += `<div>
<div style="font-weight:bold;">(初始)</div>
<div>${m.text || ""}</div>
${m.image_url ? `<div><img src="${m.image_url}" style="max-width:200px;"></div>` : ""}
<hr>
</div>`;
// 接著顯示每個更新
upds.forEach(u => {
var e = `<div>
<div style="font-size:0.9em; color:#444;">${u.updated_at}</div>
<div>${u.text || ""}</div>
${u.image_url ? `<div><img src="${u.image_url}" style="max-width:200px;"></div>` : ""}
<hr>
</div>`;
histDiv.innerHTML += e;
});
});
// …其餘提交更新等邏輯同前
};
注意:上述範例裡面 m.text
、m.image_url
需要在呼叫 openUpdateForm
時從 addMarkerToMap
內部那個 m 傳入。為此你可以改 openUpdateForm(markerId, m)
,把整個物件傳進去,而不是單純幾個參數。
舉例把 addMarkerToMap(m)
改為:
javascriptmarker.bindPopup(
`<div>... <button onclick='openUpdateForm(${m.id}, ${JSON.stringify(m)})'>查看 / 新增更新</button></div>`
);
或用閉包 (closure) 綁定事件比較乾淨,避免在 HTML 字串中直接呼叫:
javascriptmarker.on('click', function() {
openUpdateOverlay(m);
});
這樣 openUpdateOverlay(m)
裡 m
是原本的資料物件,裡面有 m.text
, m.image_url
。接著 overlay 裡就可以把主表 + 更新紀錄一起顯示。
最後,在提交更新成功後,你要重整該 marker 的顯示 popup 或重載整體地圖的 markers,以反映新更新。
已經用子表 marker_updates
記錄歷史更新
前端在 popup / overlay 裡要把主表資料 (text + image) 當成「初始紀錄」展示
再把更新紀錄一筆一筆依時間列出
更新成功後重新載入該 marker 或整張地圖,以顯示最新 + 所有歷史資訊
如果你願意的話,我可以幫你把剛剛那個完整範本再次整合這些修改,然後把修正後的完整專案給你。要嗎?
下面是修正後可完整運作的專案範本,已經整合「主表 + 更新歷史並全數顯示」功能。你可以把它複製到你的機器上,測試、修改、延伸。
pgsqlflask_marker_with_history/
│
├── app.py
├── schema.sql
├── templates/
│ └── index.html
└── static/
├── uploads/ ← 圖片上傳儲存資料夾(要建立)
├── css/
│ └── style.css
└── js/
└── map.js
確保 static/uploads/
資料夾存在,讓圖片能寫入。
schema.sql
sql-- schema.sql
DROP TABLE IF EXISTS marker_updates;
DROP TABLE IF EXISTS markers;
CREATE TABLE markers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lng REAL NOT NULL,
text TEXT,
image_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE marker_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
marker_id INTEGER NOT NULL,
text TEXT,
image_path TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(marker_id) REFERENCES markers(id)
);
app.py
pythonimport os
import sqlite3
from flask import Flask, render_template, request, jsonify, g
from werkzeug.utils import secure_filename
app = Flask(__name__)
# 圖片上傳資料夾
UPLOAD_FOLDER = os.path.join(app.root_path, 'static', 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
# 資料庫檔案路徑
DATABASE = os.path.join(app.root_path, 'markers.db')
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_db():
db = getattr(g, '_database', None)
if db is None:
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
g._database = conn
return g._database
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()
def init_db():
db = get_db()
with app.open_resource('schema.sql', mode='r') as f:
db.cursor().executescript(f.read())
db.commit()
@app.route('/initdb')
def initdb_route():
init_db()
return "Database initialized!"
@app.route('/')
def index():
return render_template('index.html')
@app.route('/markers', methods=['GET'])
def get_markers():
db = get_db()
cur = db.execute("SELECT id, lat, lng, text, image_path, created_at FROM markers")
rows = cur.fetchall()
out = []
for r in rows:
out.append({
"id": r["id"],
"lat": r["lat"],
"lng": r["lng"],
"text": r["text"],
"image_url": r["image_path"],
"created_at": r["created_at"]
})
return jsonify(out)
@app.route('/markers', methods=['POST'])
def post_marker():
lat = request.form.get('lat')
lng = request.form.get('lng')
text = request.form.get('text', "")
file = request.files.get('image')
if lat is None or lng is None:
return jsonify({"error": "lat or lng missing"}), 400
image_path = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
name, ext = os.path.splitext(filename)
# 為避免檔名衝突,加入時間戳
filename = f"{name}_{int(sqlite3.time.time())}{ext}"
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
image_path = f"/static/uploads/{filename}"
db = get_db()
cur = db.cursor()
cur.execute(
"INSERT INTO markers (lat, lng, text, image_path) VALUES (?, ?, ?, ?)",
(float(lat), float(lng), text, image_path)
)
db.commit()
new_id = cur.lastrowid
return jsonify({
"id": new_id,
"lat": float(lat),
"lng": float(lng),
"text": text,
"image_url": image_path
}), 201
@app.route('/markers/<int:marker_id>/updates', methods=['POST'])
def post_marker_update(marker_id):
db = get_db()
cur = db.execute("SELECT id, text, image_path FROM markers WHERE id = ?", (marker_id,))
base = cur.fetchone()
if base is None:
return jsonify({"error": "marker not found"}), 404
text = request.form.get('text', None)
file = request.files.get('image')
image_path = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
name, ext = os.path.splitext(filename)
filename = f"{name}_{int(sqlite3.time.time())}{ext}"
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
image_path = f"/static/uploads/{filename}"
# 若都沒更新內容
if (text is None or text.strip() == "") and image_path is None:
return jsonify({"error": "nothing to update"}), 400
cur2 = db.cursor()
cur2.execute(
"INSERT INTO marker_updates (marker_id, text, image_path) VALUES (?, ?, ?)",
(marker_id, text, image_path)
)
db.commit()
new_upd_id = cur2.lastrowid
return jsonify({
"update_id": new_upd_id,
"marker_id": marker_id,
"text": text,
"image_url": image_path
}), 201
@app.route('/markers/<int:marker_id>/updates', methods=['GET'])
def get_marker_updates(marker_id):
db = get_db()
cur = db.execute("SELECT id, text, image_path, updated_at FROM marker_updates WHERE marker_id = ? ORDER BY updated_at ASC",
(marker_id,))
rows = cur.fetchall()
out = []
for r in rows:
out.append({
"update_id": r["id"],
"text": r["text"],
"image_url": r["image_path"],
"updated_at": r["updated_at"]
})
return jsonify(out)
if __name__ == '__main__':
if not os.path.exists(DATABASE):
with app.app_context():
init_db()
app.run(debug=True)
templates/index.html
html<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>災情地圖 + 歷史更新範本</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha512‑…" crossorigin=""/>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<h2>災情地圖(可新增標記、點標記查看 / 新增更新)</h2>
<div id="map" style="height: 600px;"></div>
<!-- 新增標記用表單 -->
<div id="formPopup" style="display: none; position: absolute; background: white; padding: 10px; border: 1px solid #666;">
<form id="markerForm">
<input type="hidden" id="lat" name="lat">
<input type="hidden" id="lng" name="lng">
<div>
<label>文字描述:</label><br>
<textarea name="text" id="text" rows="3" cols="30"></textarea>
</div>
<div>
<label>上傳圖片:</label>
<input type="file" name="image" id="imageFile" accept="image/*">
</div>
<div style="margin-top: 5px;">
<button type="submit">送出</button>
<button type="button" id="cancelBtn">取消</button>
</div>
</form>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha512‑…" crossorigin=""></script>
<script src="{{ url_for('static', filename='js/map.js') }}"></script>
</body>
</html>
static/js/map.js
javascriptvar map = L.map('map').setView([23.5, 121.5], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// 載入所有 marker
function loadMarkers() {
fetch('/markers')
.then(res => res.json())
.then(data => {
data.forEach(m => {
addMarkerToMap(m);
});
});
}
// 加入 marker 並綁定點擊展開 popup / 更新功能
function addMarkerToMap(m) {
var marker = L.marker([m.lat, m.lng]).addTo(map);
// 在 click 時動作,而不是直接在 popup HTML 呼叫 openUpdateForm
marker.on('click', function() {
openUpdateOverlay(m);
});
// 初始 popup 可顯示基本資料 + 提示可以點擊查看更新
var popupHtml = `<div>
<strong>初始描述:</strong><br>
${m.text || ""}<br>
`;
if (m.image_url) {
popupHtml += `<div><img src="${m.image_url}" style="max-width:200px;"></div>`;
}
popupHtml += `<hr><div style="font-size:0.9em; color:#555;">(點此標記可查看 / 新增更新)</div></div>`;
marker.bindPopup(popupHtml);
}
// 點 marker 時開 overlay 顯示歷史 + 更新表單
function openUpdateOverlay(m) {
// 建 overlay
var overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.left = '0'; overlay.style.top = '0';
overlay.style.width = '100%'; overlay.style.height = '100%';
overlay.style.background = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '10000';
var box = document.createElement('div');
box.style.background = 'white';
box.style.margin = '50px auto';
box.style.padding = '20px';
box.style.maxHeight = '80%';
box.style.overflow = 'auto';
box.style.width = '400px';
box.innerHTML = `
<h3>標記更新 / 歷史紀錄</h3>
<div id="hist"></div>
<hr>
<div>
<textarea id="upd_text" placeholder="輸入描述" rows="3" cols="40"></textarea>
</div>
<div>
<input type="file" id="upd_image" accept="image/*">
</div>
<div style="margin-top: 10px;">
<button id="upd_submit">送出更新</button>
<button id="upd_cancel">取消</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
// 顯示初始資料 + 歷史更新
var histDiv = box.querySelector('#hist');
histDiv.innerHTML = `<h4>歷史紀錄:</h4>`;
// 初始描述
histDiv.innerHTML += `<div>
<div style="font-weight:bold;">(初始)</div>
<div>${m.text || ""}</div>
${m.image_url ? `<div><img src="${m.image_url}" style="max-width:200px;"></div>` : ""}
<hr>
</div>`;
// 取得其他更新
fetch(`/markers/${m.id}/updates`)
.then(res => res.json())
.then(upds => {
upds.forEach(u => {
var e = `<div>
<div style="font-size:0.9em; color:#444;">${u.updated_at}</div>
<div>${u.text || ""}</div>
${u.image_url ? `<div><img src="${u.image_url}" style="max-width:200px;"></div>` : ""}
<hr>
</div>`;
histDiv.innerHTML += e;
});
});
box.querySelector('#upd_cancel').addEventListener('click', function(){
document.body.removeChild(overlay);
});
box.querySelector('#upd_submit').addEventListener('click', function(){
var newText = box.querySelector('#upd_text').value;
var newImage = box.querySelector('#upd_image').files[0];
var fd = new FormData();
fd.append('text', newText);
if (newImage) {
fd.append('image', newImage);
}
fetch(`/markers/${m.id}/updates`, {
method: 'POST',
body: fd
})
.then(res => res.json())
.then(ret => {
alert('更新成功');
document.body.removeChild(overlay);
// 重新載入整個地圖標記(簡單做法)
map.eachLayer(function (layer) {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
loadMarkers();
})
.catch(err => {
console.error('更新失敗', err);
alert('更新失敗');
});
});
}
// 新增標記表單顯示 / 提交
map.on('click', function(e) {
var px = map.latLngToContainerPoint(e.latlng);
var formDiv = document.getElementById('formPopup');
formDiv.style.left = px.x + 'px';
formDiv.style.top = px.y + 'px';
formDiv.style.display = 'block';
document.getElementById('lat').value = e.latlng.lat;
document.getElementById('lng').value = e.latlng.lng;
});
document.getElementById('markerForm').addEventListener('submit', function(evt) {
evt.preventDefault();
var form = evt.target;
var formData = new FormData(form);
fetch('/markers', {
method: 'POST',
body: formData
})
.then(res => res.json())
.then(m => {
addMarkerToMap(m);
document.getElementById('formPopup').style.display = 'none';
form.reset();
})
.catch(err => {
console.error('上傳失敗', err);
alert('上傳失敗');
});
});
document.getElementById('cancelBtn').addEventListener('click', function() {
document.getElementById('formPopup').style.display = 'none';
});
// 頁面載入時執行
loadMarkers();
static/css/style.css
cssbody {
font-family: Arial, sans-serif;
}
#formPopup {
z-index: 1000;
background: white;
}
安裝 Flask:
bashpip install flask
確認資料夾結構正確,並且 static/uploads/
存在可寫入。
啟動應用:
bashpython app.py
若是第一次運行,markers.db
不存在,程式會自動執行 init_db()
,依 schema.sql
建立表格。你也可手動瀏覽 http://127.0.0.1:5000/initdb
強制初始化(注意:這會清除所有資料)。
在瀏覽器開啟 http://127.0.0.1:5000/
。
點地圖任意處會彈出「新增標記」表單,你可以輸入文字與上傳圖片。提交後,地圖上會出現新標記。
點那個標記,會彈出 overlay 視窗,裡面會顯示:
初始資料(主表的文字 + 圖片)
歷史更新:所有過去對該標記提交的更新(文字 + 圖片 + 更新時間)
下方還有一個區域可以讓你輸入新的文字 / 選擇新圖片來做更新
更新成功後,overlay 關閉,地圖重新載入標記,並在標記 popup / overlay 中顯示最新 + 所有歷史資訊。
如果你願意,我可以幫你把這個專案上傳到 GitHub,給你一個下載連結,也可以幫你部署到某個雲端(例如 Heroku / Render / Vercel),讓你可以直接在網路上測試。要嗎?
我可以一步步教你怎麼把那個 Flask 專案部署到 Render。只是要特別提醒一件很重要的事:Render 的檔案系統是 暫存型(ephemeral) 的,也就是說你的 SQLite 資料庫檔案和上傳的圖片若直接寫在本地檔案系統,可能在重啟或下次部署時消失。這部份需要用 Render 提供的「Disk」或改用雲端儲存 / 外部資料庫(例如 Postgres + S3)才能讓資料持久化。 Render+2Render+2
下面我先教你一般部署流程 + 如何處理資料持久化的選項。
在部署之前,建議你做以下準備:
建立 requirements.txt
在你的專案根目錄,執行:
bashpip freeze > requirements.txt
確保 Flask
、gunicorn
(生產用 WSGI server)、其他套件(如 sqlite3 通常是標準庫,不用列)被列進去。
設定啟動指令(start command)
在 Render 上使用 gunicorn
比直接用 python app.py
好。假設你的 Flask app 在 app.py
中,且物件叫 app
,則啟動指令可以是:
nginxgunicorn app:app
(如果你的檔案結構或命名不同,要改成對應的:gunicorn MODULE:APPVARIABLE
)
修改程式以適應環境變數 / 配置
讓你的程式在「啟動時若資料庫檔案不存在」時自動建立資料庫結構(像之前的 init_db()
)
若你改用外部資料庫(如 Postgres),程式要從環境變數讀取資料庫 URL
圖片上傳部分可能要改成用雲端儲存(如 AWS S3 / GCP Storage / Render 的 Disk)
版本控制上傳到 GitHub / GitLab / Bitbucket
Render 可以從 GitHub 或其他 Git 就直接拉代碼部署。
設定 .gitignore
,忽略 markers.db
、static/uploads/
之類不必要版本控制的檔案(視你的設計)。
以下是標準流程:
登入 Render(若還沒帳號要註冊)
在 Dashboard 點選 New → Web Service
選擇你剛剛上傳代碼的 Git 儲存庫
填寫參數:
項目 | 值/範例 |
---|---|
Environment / Language | Python 3 |
Build Command | pip install -r requirements.txt |
Start Command | gunicorn app:app |
Instance Type | 根據付費計畫選(free / standard / etc) |
確認一切設定無誤後,按 Deploy。Render 會自動拉代碼、執行 build 指令、啟動你的 App。
這就是 Render 官方教學中部署 Flask 的方式。 Render
如前面提到,Render 的檔案系統是暫存性的:當你部署、重啟或更新時,先前寫入的檔案可能會被重置。這對於 SQLite 檔案或上傳的圖片是一個大問題。很多人在論壇反映他們在 Render 部署 SQLite 時資料被重置就是這原因。 Render
你有幾個方案來解決:
Render 提供一個 Persistent Disk(也稱作附加磁碟)服務,你可以把 SQLite 檔案、上傳資料夾放到這個 Disk 上,讓檔案不會因重啟而消失。你需要在 Render 上創一個 Disk,並在 Web Service 中掛載這個 Disk,並在 Flask 程式中把 UPLOAD_FOLDER
與 DATABASE
指向該 Disk 的路徑。
Render 官網關於 Data Persistence 有說明。 app-generator.dev
這個方式更穩健,也是大多數專案會採用的做法:
將 SQLite 換成 PostgreSQL(Render 本身提供 Postgres DB 的服務)
將上傳圖片改為存儲在雲端物件儲存(例如 AWS S3、Google Cloud Storage、Render Disk)
程式啟動時連接 Postgres(使用環境變數 DATABASE_URL
或 DATABASE_URI
)
不在本地檔案系統儲存資料庫與圖片,避免資料遺失
很多人部署 Flask + Postgres 在 Render 上都有成功案例。以下是一些參考資源:
Deploy Flask + Postgres on Render 教學 app-generator.dev+2Gist+2
在程式中處理 Postgres 的環境變數替換(若 URL 是 postgres://
要替換成 postgresql://
) app-generator.dev
下面提供一個推薦流程,假設你要做比較可靠的部署:
在 Render 建立 PostgreSQL Database
在 Render Dashboard → New → PostgreSQL
選擇免費 plan 或付費視需求
建立完成後,Render 會給你一個資料庫連線 URL(DATABASE_URL
)
在 Web Service 中設定環境變數
在你的 Web Service 配置頁面,加入環境變數 DATABASE_URL
(把 Render 提供的那條 URL 貼上)
如果你的程式需要,也可設定 FLASK_ENV = production
、SECRET_KEY
等變數
修改你的 Flask 程式使用 PostgreSQL
例如改用 SQLAlchemy:
pythonfrom flask import Flask
from flask_sqlalchemy import SQLAlchemy
import os
app = Flask(__name__)
database_url = os.environ.get('DATABASE_URL')
if database_url and database_url.startswith("postgres://"):
database_url = database_url.replace("postgres://", "postgresql://", 1)
app.config['SQLALCHEMY_DATABASE_URI'] = database_url
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
class Marker(db.Model):
id = db.Column(db.Integer, primary_key=True)
lat = db.Column(db.Float, nullable=False)
lng = db.Column(db.Float, nullable=False)
text = db.Column(db.Text)
image_path = db.Column(db.String)
created_at = db.Column(db.DateTime, server_default=db.func.now())
class MarkerUpdate(db.Model):
id = db.Column(db.Integer, primary_key=True)
marker_id = db.Column(db.Integer, db.ForeignKey('marker.id'), nullable=False)
text = db.Column(db.Text)
image_path = db.Column(db.String)
updated_at = db.Column(db.DateTime, server_default=db.func.now())
然後在程式啟動時做 db.create_all()
(或用 flask-migrate 做 migration)。
處理圖片上傳與儲存
如果你用 Render Disk:掛載該 Disk,並把 UPLOAD_FOLDER
指向該掛載路徑
如果你用外部雲端儲存:把檔案上傳到 S3 / GCS,然後在資料庫記錄圖片 URL
修改 requirements.txt
加上 psycopg2-binary
或 psycopg2
部署
把程式 push 到 GitHub
在 Render Web Service 的 repo 連結設定好
Build / Deploy
測試
測試建立 marker、更新 marker、上傳圖片
重啟服務看資料是否還在
如果你願意,我可以幫你把你現在那個 Flask + SQLite + 更新歷史的專案改寫成使用 Postgres + S3 或 Render Disk 的版本,並給你一個可以直接部署到 Render 的完整版本。要嗎?
好的,下面我給你一個可以直接部署到 Render 的完整版本範例,以及一步步設定與注意事項。這個版本會用 SQLite + Render 的 Persistent Disk 來做到檔案與資料庫持久化(也就是圖片與 .db
檔案不會因重啟或部署而消失)。你可以直接把它當作樣板,修改裡面的細節(路徑、欄位、UI 等)。
我也會說明在 Render 上如何設定 “Disk”、如何掛載,以及 Render 上的部署流程與可能遇到的坑。
我們先看一下最終專案結構,以及哪些地方要特別留意:
pgsqlflask_render_disk_project/
│
├── app.py
├── schema.sql
├── requirements.txt
├── Procfile
├── templates/
│ └── index.html
└── static/
├── uploads/ ← 可能預設空目錄(實際掛載會用 Disk)
├── css/
│ └── style.css
└── js/
└── map.js
重點是:app.py
裡的資料庫檔案與上傳資料夾要指向 掛載在 Render Disk 上的路徑,而不是預設的本地相對路徑。這樣當伺服器重啟或你重新部署時,資料與圖片才不會遺失。
下面是每個檔案的內容範例。
requirements.txt
列出專案所需的 Python 套件:
nginxFlask
gunicorn
(如果你還用 werkzeug
、sqlite3
之類標準庫的就不需列,但 Flask、gunicorn 要有。)
Procfile
這個檔案告訴 Render 啟動你的應用的指令。內容可以是:
makefileweb: gunicorn app:app
意思是啟動一個 web 服務,用 gunicorn
載入 app.py
裡的 app
物件。
schema.sql
這是初始化資料庫的 SQL 腳本,對應你的主表與更新紀錄表:
sql-- schema.sql
DROP TABLE IF EXISTS marker_updates;
DROP TABLE IF EXISTS markers;
CREATE TABLE markers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lng REAL NOT NULL,
text TEXT,
image_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE marker_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
marker_id INTEGER NOT NULL,
text TEXT,
image_path TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(marker_id) REFERENCES markers(id)
);
app.py
這是核心 Flask 程式。重點在於:資料庫與上傳檔案路徑要指向 Render Disk 的掛載點。以下範例假設你在 Render 上將 Disk 掛載於 /mnt/data
(你部署時自己設定的掛載路徑,以下要一致):
pythonimport os
import sqlite3
from flask import Flask, render_template, request, jsonify, g
from werkzeug.utils import secure_filename
app = Flask(__name__)
# ========== 設定資料庫與上傳資料夾 ==========
# 這個路徑為 Render Disk 的掛載點(你部署時設定)
DISK_PATH = "/mnt/data" # <- 這個要跟你在 Render 上掛載 Disk 的路徑一致
# 資料庫檔案放在 Disk 上
DATABASE = os.path.join(DISK_PATH, "markers.db")
# 圖片上傳資料夾也設在 Disk 上
UPLOAD_FOLDER = os.path.join(DISK_PATH, "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
# 允許的圖片副檔名
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
# ========== 資料庫連線與初始化 ==========
def get_db():
db = getattr(g, '_database', None)
if db is None:
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
g._database = conn
return g._database
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()
def init_db():
db = get_db()
with app.open_resource('schema.sql', mode='r') as f:
db.cursor().executescript(f.read())
db.commit()
@app.route('/initdb')
def initdb_route():
# 注意:在正式環境時,你可能不希望暴露這條 route
init_db()
return "Database initialized!"
# ========== 路由 ==========
@app.route('/')
def index():
return render_template('index.html')
@app.route('/markers', methods=['GET'])
def get_markers():
db = get_db()
cur = db.execute("SELECT id, lat, lng, text, image_path, created_at FROM markers")
rows = cur.fetchall()
out = []
for r in rows:
out.append({
"id": r["id"],
"lat": r["lat"],
"lng": r["lng"],
"text": r["text"],
"image_url": r["image_path"],
"created_at": r["created_at"]
})
return jsonify(out)
@app.route('/markers', methods=['POST'])
def post_marker():
lat = request.form.get('lat')
lng = request.form.get('lng')
text = request.form.get('text', "")
file = request.files.get('image')
if lat is None or lng is None:
return jsonify({"error": "lat or lng missing"}), 400
image_path = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
name, ext = os.path.splitext(filename)
# 加時間戳避免檔名衝突
filename = f"{name}_{int(sqlite3.time.time())}{ext}"
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
image_path = f"/uploads/{filename}"
db = get_db()
cur = db.cursor()
cur.execute(
"INSERT INTO markers (lat, lng, text, image_path) VALUES (?, ?, ?, ?)",
(float(lat), float(lng), text, image_path)
)
db.commit()
new_id = cur.lastrowid
return jsonify({
"id": new_id,
"lat": float(lat),
"lng": float(lng),
"text": text,
"image_url": image_path
}), 201
@app.route('/markers/<int:marker_id>/updates', methods=['POST'])
def post_marker_update(marker_id):
db = get_db()
cur = db.execute("SELECT id, text, image_path FROM markers WHERE id = ?", (marker_id,))
base = cur.fetchone()
if base is None:
return jsonify({"error": "marker not found"}), 404
text = request.form.get('text', None)
file = request.files.get('image')
image_path = None
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
name, ext = os.path.splitext(filename)
filename = f"{name}_{int(sqlite3.time.time())}{ext}"
savepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(savepath)
image_path = f"/uploads/{filename}"
if (text is None or text.strip() == "") and image_path is None:
return jsonify({"error": "nothing to update"}), 400
cur2 = db.cursor()
cur2.execute(
"INSERT INTO marker_updates (marker_id, text, image_path) VALUES (?, ?, ?)",
(marker_id, text, image_path)
)
db.commit()
new_upd_id = cur2.lastrowid
return jsonify({
"update_id": new_upd_id,
"marker_id": marker_id,
"text": text,
"image_url": image_path
}), 201
@app.route('/markers/<int:marker_id>/updates', methods=['GET'])
def get_marker_updates(marker_id):
db = get_db()
cur = db.execute(
"SELECT id, text, image_path, updated_at FROM marker_updates WHERE marker_id = ? ORDER BY updated_at ASC",
(marker_id,)
)
rows = cur.fetchall()
out = []
for r in rows:
out.append({
"update_id": r["id"],
"text": r["text"],
"image_url": r["image_path"],
"updated_at": r["updated_at"]
})
return jsonify(out)
if __name__ == '__main__':
# 如果資料庫檔案不存在,就初始化
if not os.path.exists(DATABASE):
with app.app_context():
init_db()
app.run(host="0.0.0.0", port=os.environ.get("PORT", 5000))
前端檔案(templates/index.html
、static/js/map.js
、static/css/style.css
)可以照前面你已有的歷史版本,或用我之前幫你做的那個完整版。重點是前端圖像 URL 要對應到 image_url
路徑(例如 /uploads/filename
),因為在 Render 上我們會把 /uploads
靜態資源從 Disk 上取。
你可能要在 app.py
加一條設定,讓 Flask 能正確服務這些靜態上傳圖片,範例如下:
python# 在 app.py 裡加這段
@app.route('/uploads/<path:filename>')
def uploaded_file(filename):
# 從硬碟的 UPLOAD_FOLDER 回傳圖片
return flask.send_from_directory(app.config['UPLOAD_FOLDER'], filename)
(要 import flask
或 from flask import send_from_directory
)
以下是你在 Render 平台上要做的步驟:
創建一個 Persistent Disk
在 Render Dashboard → Disks → New Disk
選擇大小(例如 10GB)、區域(跟你的 Web Service 相同區域)
在設定中填你要掛載的路徑,假設你選 /mnt/data
記下那個掛載路徑(就是上面程式裡 DISK_PATH = "/mnt/data"
那段要配對的地方)
建立 Web Service
在 Render → New → Web Service
連結到你存放上面專案(包含 app.py
、Procfile
、requirements.txt
等)的 Git 儲存庫
Build Command: pip install -r requirements.txt
Start Command: gunicorn app:app
在 Advanced Settings 裡 掛載剛剛建立的 Disk,把它掛到 /mnt/data
(或你選擇的 Disk 路徑)
環境變數 / 配置
若你有密鑰或其他設定,可以在 Web Service 的環境變數頁面填入
確保端口設定通常是 PORT
環境變數(Flask 程式通常讀 os.environ["PORT"]
)
部署
點 Deploy,Render 會拉你的代碼、安裝套件、啟動 Web Service
初次部署後,可以在瀏覽器訪問 https://your-service-url/initdb
(如果你才部署好)來初始化資料庫(執行 schema.sql
)
或者讓 app.py
在啟動時如果檔案不存在就自動執行初始化(我在程式裡已寫這樣)
測試與使用
開瀏覽器到 https://your-service-url/
,試著點地圖、新增標記、上傳圖片
點標記查看更新歷史、再新增更新,看看圖片與文字是否正確顯示
重啟服務或重新部署一次,確認圖片與資料仍然存在
Render 的本地檔案系統是暫存的,任何寫入預設檔案系統的檔案(未掛載 Disk)在重啟/部署後都會消失。要讓圖片與 .db
檔案持久化,必須把它們存在你掛載的 Disk 上。 Render+2Render+2
在部署時,掛載 Disk 的路徑必須與你的程式中所使用的路徑一致。比如 Disk 掛在 /mnt/data
,那就是你 DISK_PATH
的值。否則你的程式會讀不到檔案或存錯地方。
如果過度依賴 SQLite + Disk,當使用者很多時可能效能有瓶頸。若專案逐漸長大,可能考慮改用 Postgres 或其他資料庫。
掛載 Disk 的服務通常有幾秒鐘停機時間;對於 zero-downtime 部署會有限制。
在 app.py
要加入靜態路由 (send_from_directory) 來提供 /uploads/...
圖片服務。
initdb
的 route 在生產環境應該隱藏或移除,以免被惡意呼叫重新初始化。
Deploy 完後可使用 Render 的 Log 功能查看啟動錯誤、路徑錯誤等問題。
如果你願意,我可以幫你把這個整個範本(含上面 Flask + Disk 的版本)打包到 GitHub,給你一個連結,讓你直接在 Render 上 Clone 部署。要嗎?
我查了一下,Render 的 Persistent Disk (持久磁碟 / 附加磁碟)並沒有被取消 — 它仍然是 Render 官方支援的功能。你可以在 Render 的文件中看到:
“You can attach a persistent disk to a paid Render web service, private service, or background worker. … Any filesystem changes under this path are preserved across deploys and restarts.” Render
不過,Persistent Disk 有一些限制、使用方式與條件,可能是你在你的帳號或服務中看不到該選項的原因。下面是幾個可能的原因 + 解決方案:
服務類型 / 方案不支援磁碟
Render 文件指出:
你需要的是付費的 Render Web Service(或私有服務、background worker)來附加 persistent disk。免費方案或某些服務可能不支援掛載磁碟。 Render+1
如果你用的是免費方案或某些限制較多的服務型態,Persistent Disk 的選項可能被隱藏或不可用。
創建服務時錯過 “Advanced” / “附加磁碟” 的選項
在建立 Web Service 時,在 Advanced 區域通常會有 “Attach a Disk” 或 “Persistent Disk” 的選項。若在建立時沒勾選,之後在服務設定中可以加上磁碟。 Render+1
你可以進入你的服務設定頁面,查看 “Disks” 或 “Storage / Volumes / Persistent Disk” 的 tab,是否可以 “Add Disk”。
方案/資源限制或區域支援
有些區域、或某些資源限制(帳號層級或方案層級)可能使得某些功能被限制。你可以查你的帳號方案文件或聯絡 Render 支援確認。
已建立服務而尚未掛載磁碟
即使你的帳號或方案支援 Persistent Disk,也可能尚未為你的服務新增磁碟。在服務設定或 “Disks” 頁面,通常會有 “Add Disk” 或 “Attach Disk” 的按鈕。
在 Render Dashboard → 進入你的 Web Service → 查找 “Disks” 或 “Storage / Volumes” 的設定,看看是否有附件磁碟的選項
若無法看到,檢查你的服務是否為付費方案,以及服務類型是否允許附加磁碟
若你已建立服務但忘了掛磁碟,可在服務設定中後續新增磁碟
若你確認你的方案、類型都可以支援,卻仍無法看到,建議聯絡 Render 支援詢問(可能帳號權限或界面問題)
如果你願意,我可以幫你檢查你目前的方案是否支援 Persistent Disk,或是給你一份截圖教學讓你在 Render 界面上找那個選項。要我幫你嗎?
在做這件事前,我先說明:在 Vercel 上部署你的 Flask + SQLite + 圖片上傳專案,幾乎無法保證檔案與資料庫持久化,因為 Vercel 的運算是以「無狀態(stateless)serverless function」為主,其執行環境中的檔案系統是暫存的,寫入的檔案在請求結束後或部署後可能就消失。這在 Vercel 上是已知限制。northflank.com
因此,如果你在 Vercel 部署,你必須改用外部儲存(例如 S3)或外部資料庫(PostgreSQL / MySQL /雲端 DB)來保存你的資料與圖片。你不能指望在 Vercel 的本地檔案系統中存放 SQLite 或圖片然後長期保留。
以下是我建議的部署架構變更:
資料庫:不要用 SQLite 存在本地。改用外部資料庫(例如你可以用 Render / AWS / Google Cloud / Heroku 的 Postgres / MySQL /雲端 DB)。
圖片 / 檔案上傳:不要存到本地檔案系統。改為上傳到雲端物件儲存,例如 AWS S3 / Google Cloud Storage /或其他可公開或受控存取的 Bucket,然後在資料庫中保圖片的 URL。
Flask Application:維持原本的後端 API(處理 marker、新增更新、取得更新紀錄等),但改寫圖片存放邏輯為上傳到 S3 等。
在 Vercel 部署為 Serverless Functions / API 路由 或使用 Vercel 的 Python 支援(如果可行)。
這樣即使每次 function 被重新啟動或佈署,資料庫與圖片都存放在外部,不會因為無狀態環境而消失。
下面是修改後的版本範本,假設你有一個外部的資料庫(例如 PostgreSQL),以及一個 S3 兼容的物件儲存空間用來存圖片。
注意:下面範本只是示意,必須填入你的資料庫連線、S3 金鑰/桶名稱等。
pgsqlflask_vercel_project/
│
├── app.py
├── schema.sql
├── requirements.txt
├── Procfile
├── templates/
│ └── index.html
└── static/
├── css/
│ └── style.css
└── js/
└── map.js
phpFlask
psycopg2-binary
boto3
gunicorn
(psycopg2-binary
用於 PostgreSQL,boto3
用於 S3 上傳)
makefileweb: gunicorn app:app
sqlDROP TABLE IF EXISTS marker_updates;
DROP TABLE IF EXISTS markers;
CREATE TABLE markers (
id SERIAL PRIMARY KEY,
lat DOUBLE PRECISION NOT NULL,
lng DOUBLE PRECISION NOT NULL,
text TEXT,
image_url TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE marker_updates (
id SERIAL PRIMARY KEY,
marker_id INTEGER NOT NULL REFERENCES markers(id),
text TEXT,
image_url TEXT,
updated_at TIMESTAMP DEFAULT NOW()
);
pythonimport os
from flask import Flask, render_template, request, jsonify
import psycopg2
from psycopg2.extras import RealDictCursor
import boto3
from werkzeug.utils import secure_filename
import uuid
app = Flask(__name__)
# --------- 資料庫設定(從環境變數取得) ---------
DATABASE_URL = os.environ.get('DATABASE_URL') # 例如:postgres://user:pass@host:port/dbname
if DATABASE_URL and DATABASE_URL.startswith("postgres://"):
# psycopg2 現在常用的 prefix 要改成 postgresql://
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1)
def get_db_conn():
conn = psycopg2.connect(DATABASE_URL, cursor_factory=RealDictCursor)
return conn
# --------- S3(或兼容物件儲存)設定 ---------
S3_BUCKET = os.environ.get('S3_BUCKET')
S3_REGION = os.environ.get('S3_REGION')
S3_ACCESS_KEY = os.environ.get('S3_ACCESS_KEY')
S3_SECRET_KEY = os.environ.get('S3_SECRET_KEY')
s3 = boto3.client(
's3',
region_name=S3_REGION,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY
)
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def upload_file_to_s3(file, bucket, folder="uploads"):
"""上傳檔案到 S3,回傳 public URL(或可存取的 URL)"""
filename = secure_filename(file.filename)
ext = filename.rsplit('.', 1)[1].lower()
# 產生不重複的檔名
unique_name = f"{folder}/{uuid.uuid4().hex}.{ext}"
s3.upload_fileobj(file, bucket, unique_name, ExtraArgs={'ACL': 'public-read'})
# 建構 URL(視你的 S3 endpoint 而定)
url = f"https://{bucket}.s3.{S3_REGION}.amazonaws.com/{unique_name}"
return url
# --------- 路由 ---------
@app.route('/')
def index():
return render_template('index.html')
@app.route('/markers', methods=['GET'])
def get_markers():
conn = get_db_conn()
cur = conn.cursor()
cur.execute("SELECT id, lat, lng, text, image_url, created_at FROM markers")
rows = cur.fetchall()
conn.close()
return jsonify(rows)
@app.route('/markers', methods=['POST'])
def post_marker():
lat = request.form.get('lat')
lng = request.form.get('lng')
text = request.form.get('text', "")
file = request.files.get('image')
if lat is None or lng is None:
return jsonify({"error": "lat or lng missing"}), 400
image_url = None
if file and allowed_file(file.filename):
image_url = upload_file_to_s3(file, S3_BUCKET)
conn = get_db_conn()
cur = conn.cursor()
cur.execute(
"INSERT INTO markers (lat, lng, text, image_url) VALUES (%s, %s, %s, %s) RETURNING id, lat, lng, text, image_url",
(float(lat), float(lng), text, image_url)
)
new = cur.fetchone()
conn.commit()
conn.close()
return jsonify(new), 201
@app.route('/markers/<int:marker_id>/updates', methods=['GET'])
def get_marker_updates(marker_id):
conn = get_db_conn()
cur = conn.cursor()
cur.execute(
"SELECT id AS update_id, text, image_url, updated_at FROM marker_updates WHERE marker_id = %s ORDER BY updated_at ASC",
(marker_id,)
)
rows = cur.fetchall()
conn.close()
return jsonify(rows)
@app.route('/markers/<int:marker_id>/updates', methods=['POST'])
def post_marker_update(marker_id):
text = request.form.get('text', None)
file = request.files.get('image')
image_url = None
if file and allowed_file(file.filename):
image_url = upload_file_to_s3(file, S3_BUCKET)
if (text is None or text.strip() == "") and image_url is None:
return jsonify({"error": "nothing to update"}), 400
conn = get_db_conn()
cur = conn.cursor()
cur.execute(
"INSERT INTO marker_updates (marker_id, text, image_url) VALUES (%s, %s, %s) RETURNING id AS update_id, marker_id, text, image_url",
(marker_id, text, image_url)
)
new = cur.fetchone()
conn.commit()
conn.close()
return jsonify(new), 201
if __name__ == '__main__':
app.run(debug=True)
templates/index.html
、static/js/map.js
、style.css
的結構幾乎和你之前的版本一樣,只是前端呼叫的 API 路徑不變,你把圖片顯示的 image_url
改為從資料庫取得的 S3 URL,而不是本地路徑。
因為 Vercel 的環境是 無狀態 / ephemeral 的,無法保證本地檔案系統持久保存。northflank.com
所以必須把資料與圖片存到外部系統:資料存在資料庫,圖片存在物件儲存
這樣不論 Vercel 的函式被重啟、升級、佈署,新版本仍可存取舊資料與圖片
這也是許多在 Vercel 上做有檔案/資料需求應用的通用做法
如果你願意的話,我可以幫你把上面這個 S3 + PostgreSQL 版本打包成 GitHub 專案,給你一個可以直接部署到 Vercel 的範本。要嗎?
有的,有幾個免費或低成本的方式,比較適合你現在只用 SQLite 或類似小型部署的需求。我幫你整理幾個選項及其限制,還有比較適合的替代方案。
Fly.io 是個不錯的選擇,因為它支援磁碟掛載(Volumes),比較接近你原本用 SQLite + 圖片檔案系統的設計。根據某篇中文教學,在 Fly 上使用 SQLite 時可以這樣做(建立 volume 並掛載)php.cn。
我建議你用 Fly.io 來部署這個 SQLite + 圖片上傳版本。以下是大致流程與要修改的地方:
安裝 fly CLI (flyctl
)
在你的專案目錄初始化 Fly 應用: fly launch
建立一個 volume: fly volumes create sqlite_data --size 1
在 fly.toml
裡配置 mounts
,把 volume 掛載到內部一個資料夾(例如 /data
)
在你的 app.py
或設定裡,把 DATABASE 路徑與上傳圖片資料夾設為那個掛載目錄(例如 /data/uploads
、/data/markers.db
)
部署: fly deploy
這樣即使你的應用重啟或更新,Volume 上的資料仍會保留。這是跟 Render 的 “Persistent Disk” 類似的機制。
我可以幫你把之前那個 SQLite + 圖片上傳版本改為適合 Fly.io 部署的版本(加上 volume 掛載路徑的設定、fly.toml 範本等),讓你可以直接部署。要嗎?ใช้ this approach 吧?
我查了一下,Plash(Pla.sh)確實在文件中提到支援 SQLite 原生設計 / 持久儲存 的功能。pla.sh+2plash_cli+2
以下我提供一個針對 Plash 的 SQLite 版本範本(Flask + SQLite + 圖片上傳 + 更新歷史),以及如何部署到 Plash 的步驟。但要提醒:Plash 正處於 Beta 狀態,某些功能可能還不穩定,部署過程可能有變化。
從 Plash CLI 的文件中看到:
“The docker container your app runs in has a working directory of /app which is a volume mounted to a folder that we hourly backup. Therefore, we recommend placing your sqlite database somewhere in that directory. … to prevent data loss, ensure any local database files do not clash …” GitHub
在 Plash 的首頁也寫了:
“Built‑in SQLite support saves you from complicated and expensive PostgreSQL” pla.sh
也就是說,只要你把 SQLite 檔案放到應用程式的目錄(Plash 的 /app volume 或其工作目錄),理論上會被掛載 / 備份,不會因重啟而遺失。
Plash 的 CLI 工具 plash_deploy
文件也說:
它會把除了
data/
資料夾之外的檔案覆蓋傳上去,data/ 資料夾內的檔案預設不被覆蓋**(避免部署時覆蓋資料庫)plash_cli
也就是說,你要把 SQLite 檔案放在一個叫 data/
的資料夾內(或 Plash 支援的備份區),這樣部署時不會被覆蓋。這是非常關鍵的一點。
下面是一個適合部署到 Plash 的完整範本。假設你把 SQLite 檔案與上傳圖片資料夾都放在 data/
資料夾,因為那會被 Plash 視作不被覆蓋的區域。
目錄結構範例:
pgsqlmy_plash_flask_app/
│
├── app.py
├── schema.sql
├── requirements.txt
├── plash.env ← 可存放環境變數(可選,Plash 會讀)
└── templates/
└── index.html
└── static/
├── css/
│ └── style.css
└── js/
└── map.js
└── data/
├── uploads/ ← 用來存放圖片,上傳檔案放這裡
└── markers.db ← SQLite 資料庫檔案
注意:
data/
資料夾與其子資料夾uploads/
必須在你的專案目錄中存在,並加入版本控制(空資料夾可能要放.gitkeep
)或確保部署時這些目錄存在。
下面是每個檔案的內容。
requirements.txt
textFlask gunicorn
(再加上如果有其他套件就列上去)
schema.sql
sql-- schema.sql
DROP TABLE IF EXISTS marker_updates;
DROP TABLE IF EXISTS markers;
CREATE TABLE markers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lat REAL NOT NULL,
lng REAL NOT NULL,
text TEXT,
image_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE marker_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
marker_id INTEGER NOT NULL,
text TEXT,
image_path TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(marker_id) REFERENCES markers(id)
);
app.py
pythonimport os
import sqlite3
from flask import Flask, render_template, request, jsonify, g, send_from_directory
from werkzeug.utils import secure_filename
app = Flask(__name__)
# SQLite 檔案與上傳圖片資料夾都放在 data/ 下
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
DATA_DIR = os.path.join(BASE_DIR, 'data')
UPLOAD_FOLDER = os.path.join(DATA_DIR, 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
DATABASE = os.path.join(DATA_DIR, 'markers.db')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
def allowed_file(fn):
return '.' in fn and fn.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_db():
db = getattr(g, '_database', None)
if db is None:
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
g._database = conn
return g._database
@app.teardown_appcontext
def close_db(exc):
db = getattr(g, '_database', None)
if db is not None:
db.close()
def init_db():
db = get_db()
with open(os.path.join(BASE_DIR, 'schema.sql'), 'r', encoding='utf-8') as f:
db.cursor().executescript(f.read())
db.commit()
@app.route('/initdb')
def initdb_route():
init_db()
return "DB initialized"
@app.route('/')
def index():
return render_template('index.html')
@app.route('/uploads/<path:filename>')
def uploaded_file(filename):
# 回傳圖片檔案
return send_from_directory(UPLOAD_FOLDER, filename)
@app.route('/markers', methods=['GET'])
def get_markers():
db = get_db()
cur = db.execute("SELECT id, lat, lng, text, image_path, created_at FROM markers")
rows = cur.fetchall()
out = []
for r in rows:
out.append({
"id": r["id"],
"lat": r["lat"],
"lng": r["lng"],
"text": r["text"],
"image_url": ("/uploads/" + r["image_path"]) if r["image_path"] else None,
"created_at": r["created_at"]
})
return jsonify(out)
@app.route('/markers', methods=['POST'])
def post_marker():
lat = request.form.get('lat')
lng = request.form.get('lng')
text = request.form.get('text', "")
file = request.files.get('image')
if lat is None or lng is None:
return jsonify({"error": "lat/lng missing"}), 400
image_path = None
if file and allowed_file(file.filename):
fn = secure_filename(file.filename)
name, ext = os.path.splitext(fn)
# 為避免衝突,用 timestamp 或隨機字串
fn = f"{name}_{int(sqlite3.time.time())}{ext}"
save_to = os.path.join(UPLOAD_FOLDER, fn)
file.save(save_to)
image_path = fn
db = get_db()
cur = db.cursor()
cur.execute(
"INSERT INTO markers (lat, lng, text, image_path) VALUES (?, ?, ?, ?)",
(float(lat), float(lng), text, image_path)
)
db.commit()
new_id = cur.lastrowid
return jsonify({
"id": new_id,
"lat": float(lat),
"lng": float(lng),
"text": text,
"image_url": ("/uploads/" + image_path) if image_path else None
}), 201
@app.route('/markers/<int:marker_id>/updates', methods=['GET'])
def get_marker_updates(marker_id):
db = get_db()
cur = db.execute(
"SELECT id AS update_id, text, image_path, updated_at FROM marker_updates WHERE marker_id = ? ORDER BY updated_at ASC",
(marker_id,)
)
rows = cur.fetchall()
out = []
for r in rows:
out.append({
"update_id": r["update_id"],
"text": r["text"],
"image_url": ("/uploads/" + r["image_path"]) if r["image_path"] else None,
"updated_at": r["updated_at"]
})
return jsonify(out)
@app.route('/markers/<int:marker_id>/updates', methods=['POST'])
def post_marker_update(marker_id):
text = request.form.get('text', None)
file = request.files.get('image')
image_path = None
if file and allowed_file(file.filename):
fn = secure_filename(file.filename)
name, ext = os.path.splitext(fn)
fn = f"{name}_{int(sqlite3.time.time())}{ext}"
save_to = os.path.join(UPLOAD_FOLDER, fn)
file.save(save_to)
image_path = fn
if (text is None or text.strip() == "") and image_path is None:
return jsonify({"error": "nothing to update"}), 400
db = get_db()
cur = db.cursor()
cur.execute(
"INSERT INTO marker_updates (marker_id, text, image_path) VALUES (?, ?, ?)",
(marker_id, text, image_path)
)
db.commit()
upd_id = cur.lastrowid
return jsonify({
"update_id": upd_id,
"marker_id": marker_id,
"text": text,
"image_url": ("/uploads/" + image_path) if image_path else None
}), 201
if __name__ == '__main__':
# 啟動時若資料庫不存在,初始化(開發時用)
if not os.path.exists(DATABASE):
os.makedirs(DATA_DIR, exist_ok=True)
init_db()
app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5000)))
前端的 templates/index.html
、static/js/map.js
、style.css
可跟你之前版本差不多,只是要注意圖片的 URL 是 /uploads/…
,不再是 /static/uploads/...
。
templates/index.html
(範例簡化版)html<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>災情地圖 + 更新歷史(Plash版)</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<h2>災情地圖(點擊新增+點標記查看更新)</h2>
<div id="map" style="height: 600px;"></div>
<div id="formPopup" style="display:none; position:absolute; background:white; border:1px solid #666; padding:10px;">
<form id="markerForm">
<input type="hidden" id="lat" name="lat">
<input type="hidden" id="lng" name="lng">
<div><label>文字描述:</label><br><textarea id="text" name="text" rows="3" cols="30"></textarea></div>
<div><label>上傳圖片:</label><input type="file" id="imageFile" name="image" accept="image/*"></div>
<div style="margin-top:5px;"><button type="submit">送出</button><button type="button" id="cancelBtn">取消</button></div>
</form>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="{{ url_for('static', filename='js/map.js') }}"></script>
</body>
</html>
static/js/map.js
javascriptvar map = L.map('map').setView([23.5, 121.5], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
function loadMarkers(){
fetch('/markers')
.then(r => r.json())
.then(arr => {
arr.forEach(m => addMarkerToMap(m));
});
}
function addMarkerToMap(m) {
var marker = L.marker([m.lat, m.lng]).addTo(map);
marker.on('click', function(){
openUpdateOverlay(m);
});
var html = `<div><strong>初始:</strong><br>${m.text || ""}</div>`;
if (m.image_url) {
html += `<div><img src="${m.image_url}" style="max-width:200px;"></div>`;
}
html += `<hr><div style="font-size:0.9em; color:#555;">(點圖查看/新增更新)</div>`;
marker.bindPopup(html);
}
map.on('click', function(e){
var px = map.latLngToContainerPoint(e.latlng);
var form = document.getElementById('formPopup');
form.style.left = px.x + 'px';
form.style.top = px.y + 'px';
form.style.display = 'block';
document.getElementById('lat').value = e.latlng.lat;
document.getElementById('lng').value = e.latlng.lng;
});
document.getElementById('cancelBtn').onclick = function(){
document.getElementById('formPopup').style.display = 'none';
};
document.getElementById('markerForm').onsubmit = function(evt){
evt.preventDefault();
var fd = new FormData(this);
fetch('/markers', {
method: 'POST',
body: fd
}).then(r => r.json())
.then(m => {
addMarkerToMap(m);
document.getElementById('formPopup').style.display = 'none';
this.reset();
})
.catch(e => {
console.error(e);
alert("上傳失敗");
});
};
function openUpdateOverlay(m) {
// 建 overlay 等同之前版本
var overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.left = '0'; overlay.style.top = '0';
overlay.style.width = '100%'; overlay.style.height = '100%';
overlay.style.background = 'rgba(0,0,0,0.5)';
overlay.style.zIndex = '10000';
var box = document.createElement('div');
box.style.background = 'white';
box.style.margin = '50px auto';
box.style.padding = '20px';
box.style.maxHeight = '80%';
box.style.overflow = 'auto';
box.style.width = '400px';
box.innerHTML = `
<h3>標記更新 / 歷史</h3>
<div id="hist"></div><hr>
<textarea id="upd_text" rows="3" cols="40" placeholder="輸入描述"></textarea><br>
<input type="file" id="upd_image" accept="image/*"><br>
<button id="upd_submit">送出更新</button>
<button id="upd_cancel">取消</button>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
// 顯示主表資料作為初始紀錄
var hist = box.querySelector('#hist');
hist.innerHTML = `<div style="font-weight:bold;">(初始)</div>
<div>${m.text || ""}</div>
${m.image_url ? `<div><img src="${m.image_url}" style="max-width:200px;"></div>` : ""}<hr>`;
// 取得更新歷史
fetch(`/markers/${m.id}/updates`)
.then(r => r.json())
.then(upds => {
upds.forEach(u => {
var e = `<div>
<div style="font-size:0.9em; color:#444;">${u.updated_at}</div>
<div>${u.text || ""}</div>
${u.image_url ? `<div><img src="${u.image_url}" style="max-width:200px;"></div>` : ""}
<hr>
</div>`;
hist.innerHTML += e;
});
});
box.querySelector('#upd_cancel').onclick = function(){
document.body.removeChild(overlay);
};
box.querySelector('#upd_submit').onclick = function(){
var newText = box.querySelector('#upd_text').value;
var newImage = box.querySelector('#upd_image').files[0];
var fd2 = new FormData();
fd2.append('text', newText);
if (newImage) fd2.append('image', newImage);
fetch(`/markers/${m.id}/updates`, {
method: 'POST',
body: fd2
}).then(r => r.json())
.then(ret => {
alert("更新成功");
document.body.removeChild(overlay);
// 重新載入地圖
map.eachLayer(l => {
if (l instanceof L.Marker) {
map.removeLayer(l);
}
});
loadMarkers();
})
.catch(e => {
console.error(e);
alert("更新失敗");
});
};
}
// 初次載入
loadMarkers();
在本地機器安裝 Plash CLI:
bashpip install plash-cli
登入 Plash:
bashplash_login
這會在瀏覽器中打開 Plash 帳號登入流程。plash_cli+1
在你的專案根目錄(包含 app.py
、schema.sql
、requirements.txt
、data/
等)執行:
bashplash_deploy
這會打包你的應用、上傳到 Plash 平台、Build & Deploy。plash_cli+2GitHub+2
注意:預設情況下,Plash 會保護 data/
資料夾裡的檔案不被覆蓋(這樣你的 SQLite 檔與圖片不會在新版部署時被清除)plash_cli+1
部署成功後,Plash 會給你一個網域(例如 your-app.pla.sh
),你可以透過瀏覽器訪問。pla.sh+1
若是第一次部署或 data/markers.db
不存在,你可能要先訪問 /initdb
路徑來初始化資料庫 (你也可以把程式改成啟動時自動初始化)
在之後的每次修改後,再執行 plash_deploy
更新即可。
Plash 屬於 Beta 階段,可能有不穩定或變動的行為
雖然 Plash 有掛載 / 備份 data/
的機制,但你必須確保你的 SQLite 檔案與圖片都放在 data/
目錄中
在 plash_deploy
的文件中,若你不正確管理 data/
資料夾,新的部署可能覆蓋或刪除這些檔案。--force_data
參數會覆蓋 data/
。plash_cli
不要把整個 data/
複製到其他目錄,最好就讓 data/
保持在根目錄下
若你的應用成長較大、圖片多或用戶多,SQLite + local file storage 的方式可能效能不夠,可能要評估換成 PostgreSQL + S3 等架構
如果你願意,我可以幫你把這個 Plash 版本打包成一個 GitHub 專案,給你一個可用的連結,你可以直接 plash_deploy
上去。要嗎?