顯示具有 App 標籤的文章。 顯示所有文章
顯示具有 App 標籤的文章。 顯示所有文章

2026年2月27日 星期五

Bubble.io 學習筆記 : 註冊免費帳號 (Free Plan)

Bubble.io 是一個整合前端設計, 後端邏輯, 與資料庫管理於一體的全功能無程式碼 SaaS 開發平台, 亦提供雲端託管服務, 可直接佈署平台上開發的 Web/Mobile App, 非常適合非程式設計背景的開發者將創意發想快速實現. 本系列之前的文章參考 :


Bubble.io 提供 Starter, Growth, Team, 與 Enterprise 四級付費帳戶方案, 亦有免費帳戶供使用者熟悉 Bubble 平台用法, 各方案功能與限制如下表 : 


 項目  Free  Starter  Growth  Team  Enterprise
 月費 (約略)  $0  $59/月  $209/月  $549/月  客製報價
 是否可 Live 上線  ❌  ✔️  ✔️  ✔️  ✔️
 自訂網域  ❌  ✔️  ✔️  ✔️  ✔️
 編輯者數量  1  1  2  5  客製
 Workload Units / 月  50K  175K  250K  500K  客製
 伺服器日誌保留時間  6 小時  2 天  14 天  20 天  客製
 版本控制 (Branch)  ❌  基本  10 Branches  25 Branches  完整支援
 Recurring Workflows  ❌  ✔️  ✔️  ✔️  ✔️
 2FA 安全驗證  ❌  ❌  ✔️  ✔️  ✔️


對於初學者而言, 可先註冊免費帳戶來了解 Bubble 的基本用法, 如果 App 需要上線再升級為 Starter 以上帳戶. 以下情況就一定要升級付費方案 : 
  • 想要正式對外上線 (Live App)
  • 需要自訂網域
  • 有實際用戶的商業用途
  • Workload (WU) 已不夠用
  • 需要多人協作或版本控管
本篇旨在紀錄註冊免費帳戶過程. 首先到官網按右上方的 Login 鈕 :





按底下 Sign Up 鈕 :




可以選擇以 Google 帳號 (最快) 或 Email 註冊 (密碼需含大小寫字母數字與特殊符號, 長度至少 8 個字元), 按 Start Building 會寄確認信到 Email 信箱 : 





註冊程序會直接進入 App 建置頁面, 為了展示可點選 "Internal tool", 會在輸入框自動帶入預設的員工資料管理 Web app 提示詞, 此 App 包含大部分的 Bubble 主場功能, 例如 CRUD, Workflow (條件, 狀態, 流程), 權限控制, 以及表單搜尋與篩選等 : 




按藍色的右鍵頭即開始自動建立此 Web app :




這時會顯示詢問是否要使用高級功能的 14 天試用版, 先不要啟動試用版, 按最底下的 Start with basic feature 連結即可 :


 

詢問如何得知 Bubble.io, 隨便按一個便會將 App 提示詞交給 AI : 





顯示 AI 規劃此 App 之藍圖, 按右上角的 Generate app 鈕開始生成 App : 





約幾十秒即完成 App 建置, 並彈出的意見調查表 :




填好意見調查表後按如下頁面中的 "preview my app" 就會顯示此 Web app  :





網址如下 : 


以上只是利用提示詞透過 AI 快速生成一個 Web app 的展示而已, 也可以手工操作編輯器來調整 App 的 UI 與工作流程, 後續將以實際範例進行測試. 

2026年1月27日 星期二

Windows 安裝米家 App

鄉下老家目前裡裡外外裝了七支攝影機, 平常都用手機的米家 App 關注爸的安全動態, 今天上網搜尋是否有米家 App 的電腦版, 在 Mobile1 找到這篇 :


原來在小米官網就有提供下載網址 : 





但按右上角的 Download 會得到 404, 需往下拉到步驟說明 3 底下的下載按鈕才行 : 




解壓縮下載的 zip 檔會得到一個 XiaomiCameraViewerInstaller.exe 安裝檔, 點擊執行時把預設的簡體中文改為英文 : 




然後一直按 Next 預設安裝下去即可 : 






安裝完畢執行軟體會先要求登入小米帳號, 我是用手機號碼搭配 ak 密碼登入, 會發一個 email 到谷歌信箱, 輸入驗證碼後就會看到攝影機影像了, 可以選擇 4/6/9 分割顯示畫面 :




與手機米家 App 比起來亮度較暗, 且大門口兩支 CW300 攝影機未入列, Mobile1 底下有人提到似乎 1080p 以上的都讀不到, CW300 是 2.5K 超高解析度 (2560x1440), 小米攝影機 2k 標準版都可以讀到. 

按每支攝影機右下角的照相按鈕可以手動拍照, 預設會將 jpg 檔存在桌面, 可以按右上角的帳號頭像點選 Mahage File 更改儲存位置 (例如建一個 D:\XiaomiCamera 來存圖片) : 





婷婷表妹上周為了處理小舅媽攬的外務 (幫市場阿桑解決手機監控攤位問題) 要買一支小米攝影機, 我順便叫她幫我買兩支 C201, 下標後阿桑說不用了, 我叫婷婷三支都給我 (1597 元), 本周來安裝在車庫, 祖堂, 與庫房. 

2024年8月25日 星期日

不敢安裝 LG ThinkQ

今天 LG Gram 筆電的 LG Quick Guide 軟體更新時出現下列 QR Code 頁面 :




看起來像是 LG 的智慧家居物聯網 App, 但網頁怎麼是簡 (殘) 體中文? LG 不是韓國公司嗎? 為何不是英文或韓文? 如果是偵測到作業系統語言設定, 應該是繁體中文啊! 網頁底下的客服是北京海汀區, 掃描後發現是下載 Apk 而非導引至 Google Play, 讓我不放心, 還是算了別安裝. 

2023年5月10日 星期三

好書 : AIoT實作好好玩

此書使用 Micro:bit 實作各種 AIoT 實驗, 例如以藍芽與語嫣辨識控制家電, 體感遙控以及影像辨識等等, 但借來都沒時間做實驗, 因為被預約須還, 等有空玩再回借 :



Source : 博客來


micro:bit 可以使用四種方式來控制 :
  1. Blocks : 與 Scratch 類似的積木式圖控語言, 也是此書採用的語言.
  2. MicroPython : 此為在嵌入式設備上運行的 Python 子集. 
  3. Javascript : 原為網頁前端語言, 也可以用在積木式語言的編輯器中.
  4. Scratch : micro:bit 須先安裝 Scratch Link 才能使用 Scratch.
本書是在 MakeCode 線上編輯器上使用 Blocks 語言控制 micro:bit, 其繁體中文網站為 :





按 "新增專案" 即可進入 MakeCode 編輯器 : 





與 Scratch 類似, 左方有許多程式方塊, 可用拖曳方式拉到右方編輯區中組合出運作邏輯.

2022年1月26日 星期三

jQuery Mobile 學習筆記 (二十三) : 用 Web Storage 製作備忘錄 App (中)

今天同事安裝了這款 App 試用後說還不錯, 但少了一個備忘分類的欄位, 這在以前工作日誌系統中可以很方便地用下拉式選單選取, 也可以做為搜尋欄位, 但 localStorage 就很陽春的 key:value 物件資料庫啊! 沒辦法多出其他欄位. 不過仔細想想也不是沒辦法, 至少有兩個解決方案 :
  1. 將多出來的分類欄位與備忘記事欄以字串方式串接存入 value.
  2. 將分類與備忘記事以物件方式儲存, 然後用 JSON.stringify() 轉成字串存入 value.
於是晚上便動手來試試看. 首先測試第一個方案, 參考上一篇來改 :


這次想連同 render_memo_list() 都改成用 "|" 號來串接, 因為此符號較少用, 拆分字串時誤拆的機率比較低. 儲存在 localStorage 中的物件以寫入的日期時間當作 key, 分類與備忘記事以 "|" 符號串接後做為 value, 例如 :

{"2022-01-24T08:45:27": "問候|Hello World"}
{"2022-01-24T09:31:27": "問候|你是在說哈囉嗎?"}

在函式 sort_local_storage() 中, 也是將 key 與 value 用 "|" 符號串接, 其傳回陣列例如 :

[ "2022-01-24T09:31:27|問候|你是在說哈囉嗎?", "2022-01-24T08:45:27|問候|Hello World"]

由於 key=日期時間放在最前面, 故會先以日期時間排序, 先呼叫 sort() 再呼叫 reverse() 結果最近的時間會排前面. 完整的網頁與程式碼如下 : 



<!DOCTYPE html>
<html>
  <head>
    <title></title>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <script src="https://code.jquery.com/jquery-1.11.1.min.js"></script>
    <script src="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
    <link href="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css" rel="stylesheet">
    <style>
      table {
          color: black;
          background: #fff;
          border: 1px solid #b4b4b4;
          padding: 0;
          margin-top:10px;
          width: 100%;
          -webkit-border-radius: 8px;
          }
      table tr td {
          color: #666;
          border-bottom: 1px solid #b4b4b4;
          border-right: 1px solid #b4b4b4;
          padding: 5px 5px 5px 5px;
          background-image: -webkit-linear-gradient(top, #fdfdfd, #eee);
          }
      table tr:first-child td {
          border-top: 1px solid #b4b4b4;
          }
      table tr td:last-child {
          border-right: none;
          }
      table tr:last-child td {
          border-bottom: none;
          }
    </style>
  </head>
  <body>
    <!-- 第一頁頁面 (顯示備忘事項列表) -->
    <section data-role="page" id="page1">
      <header data-role="header" data-position="fixed">
        <h1>我的備忘錄</h1>
        <a href="#confirm_clear_dialog" id="clear_memo_btn" class="ui-btn-right" data-icon="recycle" data-rel="dialog">清除</a>
      </header>
      <article data-role="content">
        <a href="#page2" data-role="button" data-icon="plus">新增事項</a>
        <table>
          <thead>
            <tr>
              <th>事項</th>
              <th>類別</th>
              <th>編輯</th>
            </tr>
          </thead>
          <tbody id="memo_list">
          </tbody>
        </table>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 第二頁頁面 (新增備忘事項) -->
    <section data-role="page" id="page2">
      <header data-role="header" data-position="fixed" data-add-back-btn="true" data-back-btn-text="返回">
        <h1>新增事項</h1>
      </header>
      <article data-role="content">
        <form data-ajax="false">
          <div data-role="fieldcontain">
            <label for="add_memo">事項 : </label>
            <textarea id="add_memo"></textarea>
            <label for="add_category">類別 : </label>
            <input type="text" id="add_category">
          </div>
          <input type="button" id="add_memo_btn" value="儲存" data-icon="check">
        </form>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 第三頁頁面 (編輯備忘事項) -->
    <section data-role="page" id="page3">
      <header data-role="header" data-position="fixed" data-add-back-btn="true" data-back-btn-text="返回">
        <h1>編輯事項</h1>
      </header>
      <article data-role="content">
        <form data-ajax="false">
          <div data-role="fieldcontain">          
            <input type="hidden" id="edit_memo_key">
            <label for="edit_memo_value">事項 : </label>
            <textarea id="edit_memo_value"></textarea>
            <label for="edit_memo_category">類別 : </label>
            <input type="text" id="edit_memo_category">
          </div>
          <div data-role="controlgroup" data-type="horizontal">
            <a href="#" data-role='button' data-rel="back" data-icon="back" data-inline="true">取消</a>
            <a href="#confirm_remove_dialog" id="remove_memo_btn" data-role='button' data-rel="dialog" data-icon="delete" data-inline="true">刪除</a>
            <a href="#" id="update_memo_btn" data-role='button' data-icon="check" data-inline="true">更新</a>
          </div>
        </form>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 對話框頁面 1 (清除全部資料) -->
    <section data-role="dialog" id="confirm_clear_dialog" data-overlay-theme="b">
      <header data-role="header">
        <h1>請確認</h1>
      </header>
      <article data-role="content">
        <p>確定要清除全部資料嗎?</p>
        <div data-role="controlgroup" data-type="horizontal">
          <a href="#" data-role='button' data-icon='back'  data-inline="true" data-rel="back">取消</a>
          <a href="#" id="confirm_clear_btn" data-role='button' data-icon='check'  data-inline="true">確定</a>
        </div>
      </article>
    </section>
    <!-- 對話框頁面 2 (刪除單筆資料) -->
    <section data-role="dialog" id="confirm_remove_dialog" data-overlay-theme="b">
      <header data-role="header">
        <h1>請確認</h1>
      </header>
      <article data-role="content">
        <p>確定要刪除這筆事項嗎?</p>
        <div data-role="controlgroup" data-type="horizontal">
          <a href="#" data-role='button' data-icon='back' data-inline="true" data-rel="back">取消</a>
          <a href="#" id="confirm_remove_btn" data-role='button' data-icon='check'  data-inline="true">確定</a>
        </div>
      </article>
    </section>
    <script>
      function sort_local_storage(){
        var arr=[];
        for (var i=0; i<localStorage.length; i++){
          var k=localStorage.key(i);
          var v=localStorage.getItem(k); 
          arr.push(k + "|" + v);
          }
        arr.sort();
        arr.reverse();
        return arr;
        }
      function set_edit_memo(k){
        var cv=localStorage.getItem(k);
        $("#edit_memo_key").val(k);
        var arr=cv.split("|");
        var c=arr[0];
        var v=arr[1];
        $("#edit_memo_value").val(v);
        $("#edit_memo_category").val(c);
        render_memo_list();
        $.mobile.changePage($("#page3"));
        }
      function render_memo_list(){
        $("#memo_list").empty();
        var memo_arr=sort_local_storage();
        var item=[];
        if (memo_arr.length == 0){
          item.push("<tr><td colspan='3' style='text-align:center;'>" + 
                    "資料庫內容為空, 請新增事項</td></tr>");
          }
        else {
          for (var i=0; i<memo_arr.length; i++){
            var arr=memo_arr[i].split("|");
            var k=arr[0];
            var c=arr[1];
            var v=arr[2];
            item.push("<tr><td>" + v + "</td><td>" + c + "</td>" + 
                      "<td style='text-align: center;'>" + 
                      "<a href='#' data-role='button' " + 
                      "data-icon='edit' data-iconpos='notext' " + 
                      "data-mini='true' data-inline='true' " + 
                      "onclick='set_edit_memo(" + '"' + k + '"' +
                      ")'>編輯</a></td></tr>");            
            }
          }
        $(item.join("")).appendTo("#memo_list").trigger("create");
        }
      $(document).one("pageinit", function(){
        render_memo_list();
        $("#add_memo_btn").on("click", function(e) {
          var k=JSON.stringify(new Date()).substr(1,19);
          var v=$("#add_category").val() + "|" + $("#add_memo").val();
          localStorage.setItem(k, v); 
          $("#add_memo").val("");
          $("#add_category").val("");
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#confirm_clear_btn").on("click", function(e) {
          localStorage.clear();
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#confirm_remove_btn").on("click", function(e) {
          var k=$("#edit_memo_key").val();
          localStorage.removeItem(k);
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#update_memo_btn").on("click", function(e) {
          var k=$("#edit_memo_key").val();
          var v=$("#edit_memo_category").val() + "|" + 
                $("#edit_memo_value").val();
          localStorage.setItem(k, v);
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        }); 
    </script>
  </body>
</html>

程式部分與前一篇的主要差別是新增了記事的類別欄位與其配套處理, 主要就是將分類冠在記事內容前面用 "|" 串接當作 value 存入 localStorage, 顯示或編輯時用 split("|") 將 key (日期時間), 分類與記事內容拆出來. 另外也改寫了 render_memo_list() 等函式, 簡化了資料庫為空時的處理方式, 結果如下 : 




 


測試 OK 後用線上服務 appgeyser 轉成 apk 檔, 可從 GitHub 下載 : 





不過這個第二版是利用 "|" 字元來將分類欄位與記事欄位串起來當作 value 存入 localStorage, 雖然 "|" 字元很少人會用到, 但萬一用在分類欄位上就會拆解時出現分類被裁減掉 "|" 字元後面的部分 (被歸入記事欄位), 所以並不完美. 

較安全的處理方式是將分類與記事兩個欄位存入物件中, 然後用 JSON.stringify() 方法轉成字串, 取出時再呼叫 JSON.parse() 方法還原成物件. 分類欄位 key 可用 category, 記事欄位 key 可用 memo,  例如 : 

{catelogue: "問候", memo: "Hello World"}
{catelogue: "問候", memo: "你是在說哈囉嗎?"}

將上面第二版程式碼中的欄位合併與拆解方式修改為物件存取方式, 成為如下之第三版 : 



<!DOCTYPE html>
<html>
  <head>
    <title></title>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <script src="https://code.jquery.com/jquery-1.11.1.min.js"></script>
    <script src="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
    <link href="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css" rel="stylesheet">
    <style>
      table {
          color: black;
          background: #fff;
          border: 1px solid #b4b4b4;
          padding: 0;
          margin-top:10px;
          width: 100%;
          -webkit-border-radius: 8px;
          }
      table tr td {
          color: #666;
          border-bottom: 1px solid #b4b4b4;
          border-right: 1px solid #b4b4b4;
          padding: 5px 5px 5px 5px;
          background-image: -webkit-linear-gradient(top, #fdfdfd, #eee);
          }
      table tr:first-child td {
          border-top: 1px solid #b4b4b4;
          }
      table tr td:last-child {
          border-right: none;
          }
      table tr:last-child td {
          border-bottom: none;
          }
    </style>
  </head>
  <body>
    <!-- 第一頁頁面 (顯示備忘事項列表) -->
    <section data-role="page" id="page1">
      <header data-role="header" data-position="fixed">
        <h1>我的備忘錄</h1>
        <a href="#confirm_clear_dialog" id="clear_memo_btn" class="ui-btn-right" data-icon="recycle" data-rel="dialog">清除</a>
      </header>
      <article data-role="content">
        <a href="#page2" data-role="button" data-icon="plus">新增事項</a>
        <table>
          <thead>
            <tr>
              <th>事項</th>
              <th>類別</th>
              <th>編輯</th>
            </tr>
          </thead>
          <tbody id="memo_list">
          </tbody>
        </table>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 第二頁頁面 (新增備忘事項) -->
    <section data-role="page" id="page2">
      <header data-role="header" data-position="fixed" data-add-back-btn="true" data-back-btn-text="返回">
        <h1>新增事項</h1>
      </header>
      <article data-role="content">
        <form data-ajax="false">
          <div data-role="fieldcontain">
            <label for="add_memo">事項 : </label>
            <textarea id="add_memo"></textarea>
            <label for="add_category">類別 : </label>
            <input type="text" id="add_category">
          </div>
          <input type="button" id="add_memo_btn" value="儲存" data-icon="check">
        </form>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 第三頁頁面 (編輯備忘事項) -->
    <section data-role="page" id="page3">
      <header data-role="header" data-position="fixed" data-add-back-btn="true" data-back-btn-text="返回">
        <h1>編輯事項</h1>
      </header>
      <article data-role="content">
        <form data-ajax="false">
          <div data-role="fieldcontain">          
            <input type="hidden" id="edit_memo_key">
            <label for="edit_memo_value">事項 : </label>
            <textarea id="edit_memo_value"></textarea>
            <label for="edit_memo_category">類別 : </label>
            <input type="text" id="edit_memo_category">
          </div>
          <div data-role="controlgroup" data-type="horizontal">
            <a href="#" data-role='button' data-rel="back" data-icon="back" data-inline="true">取消</a>
            <a href="#confirm_remove_dialog" id="remove_memo_btn" data-role='button' data-rel="dialog" data-icon="delete" data-inline="true">刪除</a>
            <a href="#" id="update_memo_btn" data-role='button' data-icon="check" data-inline="true">更新</a>
          </div>
        </form>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 對話框頁面 1 (清除全部資料) -->
    <section data-role="dialog" id="confirm_clear_dialog" data-overlay-theme="b">
      <header data-role="header">
        <h1>請確認</h1>
      </header>
      <article data-role="content">
        <p>確定要清除全部資料嗎?</p>
        <div data-role="controlgroup" data-type="horizontal">
          <a href="#" data-role='button' data-icon='back'  data-inline="true" data-rel="back">取消</a>
          <a href="#" id="confirm_clear_btn" data-role='button' data-icon='check'  data-inline="true">確定</a>
        </div>
      </article>
    </section>
    <!-- 對話框頁面 2 (刪除單筆資料) -->
    <section data-role="dialog" id="confirm_remove_dialog" data-overlay-theme="b">
      <header data-role="header">
        <h1>請確認</h1>
      </header>
      <article data-role="content">
        <p>確定要刪除這筆事項嗎?</p>
        <div data-role="controlgroup" data-type="horizontal">
          <a href="#" data-role='button' data-icon='back' data-inline="true" data-rel="back">取消</a>
          <a href="#" id="confirm_remove_btn" data-role='button' data-icon='check'  data-inline="true">確定</a>
        </div>
      </article>
    </section>
    <script>
      function sort_local_storage(){
        var arr=[];
        for (var i=0; i<localStorage.length; i++){
          var k=localStorage.key(i);
          var v=localStorage.getItem(k); 
          arr.push(k + "|" + v);
          }
        arr.sort();
        arr.reverse();
        return arr;
        }
      function set_edit_memo(k){
        var cv=localStorage.getItem(k);
        $("#edit_memo_key").val(k);
        var obj=JSON.parse(cv);   
        var c=obj.category;
        var v=obj.memo;
        $("#edit_memo_value").val(v);
        $("#edit_memo_category").val(c);
        render_memo_list();
        $.mobile.changePage($("#page3"));
        }
      function render_memo_list(){
        $("#memo_list").empty();
        var memo_arr=sort_local_storage();
        var item=[];
        if (memo_arr.length == 0){
          item.push("<tr><td colspan='3' style='text-align:center;'>" + 
                    "資料庫內容為空, 請新增事項</td></tr>");
          }
        else {
          for (var i=0; i<memo_arr.length; i++){
            var arr=memo_arr[i].split("|");
            var k=arr[0];
            var obj=JSON.parse(arr[1]);
            var c=obj.category;
            var v=obj.memo;
            item.push("<tr><td>" + v + "</td><td>" + c + "</td>" + 
                      "<td style='text-align: center;'>" + 
                      "<a href='#' data-role='button' " + 
                      "data-icon='edit' data-iconpos='notext' " + 
                      "data-mini='true' data-inline='true' " + 
                      "onclick='set_edit_memo(" + '"' + k + '"' +
                      ")'>編輯</a></td></tr>");            
            }
          }
        $(item.join("")).appendTo("#memo_list").trigger("create");
        }
      $(document).one("pageinit", function(){
        render_memo_list();
        $("#add_memo_btn").on("click", function(e) {
          var k=JSON.stringify(new Date()).substr(1,19);
          var v={category:$("#add_category").val(),
                 memo:$("#add_memo").val()};
          localStorage.setItem(k, JSON.stringify(v)); 
          $("#add_memo").val("");
          $("#add_category").val("");
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#confirm_clear_btn").on("click", function(e) {
          localStorage.clear();
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#confirm_remove_btn").on("click", function(e) {
          var k=$("#edit_memo_key").val();
          localStorage.removeItem(k);
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#update_memo_btn").on("click", function(e) {
          var k=$("#edit_memo_key").val();
          var v={category:$("#edit_memo_category").val(),
                 memo:$("#edit_memo_value").val()};
          localStorage.setItem(k, JSON.stringify(v)); 
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        }); 
    </script>
  </body>
</html>

藍色部分是與上面第 2 版不同之處, 差別只在於可防止使用者的記事類別含有 "|" 字元而已, 但缺點是會占較大儲存空間, 因為每一筆資料都會重複 catelogue 與 memo 這兩個 key, 頗為冗餘, 所以 我覺得其實用第 2 版就可以啦. 

# 下載我的備忘錄 v3 apk 檔 (使用字串化物件) 




2022-01-31 補充 :

今天發現當事項欄位很長時, 類別欄位嘿被擠壓也跳行了 : 




可以用 CSS 的 td:nth-child(2) 選取第二個 td 後設定 "white-space: nowrap;" 屬性即可, 參考 :


所以我又將上面的 v2, v3 程式碼中的樣式添加了如下設定 : 

      table tr td:nth-child(2) {
          white-space: nowrap;
          }

這樣就可以解決此問題了. 

2022年1月25日 星期二

jQuery Mobile 學習筆記 (二十二) : 用 Web Storage 製作備忘錄 App (上)

上週因為要因應 IE 走入歷史須將輔助工作自動化的網頁程式改版, 在翻閱 HTML5 書籍尋求 ActiveX 的替代技術時看到 localStorage 物件, 覺得是可行方案之一, 於是便做了一些測試, 覺得雖然僅有約 5MB 容量, 倒也足以應付工作需求. 

做完測試後剛好想起同事去年底的囑託, 希望我可以將我十幾年前幫單位寫的工作日誌系統原始碼給他, 因為覺得很好用想要做為自己的私人資訊系統, 但我說那系統太大也太舊了不合用, 現在是行動通訊時代人手一機, 有空我寫個微小版的個人工作日誌系統給他. 既然應承了, 剛好又學完 localStorage, 不如打鐵趁熱, 結合 jQuery Mobile 與 localStorage 寫個網頁應用程式吧! 還可以用線上服務轉成 App. 

同事的要求很簡單, 就是可以新增, 編輯, 以及刪除記事, 相當於以前工作日誌系統裡面的 CRUD 最基本功能, 但去掉可追蹤工作流程的堆疊式文章架構, 當然也不需要稽核要看的主管閱覽等管理功能, 純粹就是表格式的記事本. 希望保留記事類別欄位, 還可以搜尋云云, 但這對 localStoage 來說有點過分了, 等後續我測試完 indexedDB 再看看唄! 

本篇先來完成備忘錄的 CRUD 基本功能就好, 順便複習一下 jQuery Mobile 的用法. 比起現在紅到發紫的 React 等, jQuery Mobile 算是上個世紀的技術了, 雖然老狗玩不出新把戲, 但老把戲還是能用的. 

關於 jQuery Mobile 參考 : 


我的規劃是使用單頁應用架構 (SPA), 所以 CRUD 似乎需要四個 page, 但其實 Delete 動作是透過函式完成, 刪除完就跳回備忘錄列表頁面, 所以其實只需要三個 Page (C, R, 與 U), 另外加上兩個 Dialog 對話框頁面 (負責確認 removeItem 與 clear). 

在這三天測試過程中才發現我對 jQuery Mobile 的操控還是有許多盲點, 例如用程式碼 $.mobile.changePage() 換頁, 以及用 trigger("create") 來動態新增 DOM 元素, 這是這幾天才學會的新用法. 

記事列表我使用 TABLE 表格來呈現, 原本想用 ListView, 但發現要放編輯 icon 有困難, 所以還是用表格唄, 這也跟之前的工作日誌系統較像. 在 jQuery 中使用表格的方法參考 :


不過本篇用的是下列這篇文章中所使用的特殊樣式表格, 它的功能較簡單, 就是單純顯示表列資料而已, 沒有分頁功能, 下回再試試有分頁的表格 :



一. 製作 jQuery Mobile 網頁程式 : 

儲存在 localStorage 中的備忘記事以格式為 YYYY-MM-DDTHH:mm:SS 的日期時間為鍵, 以記事內容為值, 例如 :

{"2022-01-24T08:45:27": "Hello World"}
{"2022-01-24T09:31:27": "你是在說哈囉嗎?"}

其中 key 是用呼叫 new Date() 得到的日期時間物件傳給 JSON.stringify() 轉成字串後再取出前 19 個字元而得. 省略秒後面的資訊, 畢竟一般不會在同一秒內輸入兩筆備忘事項. 

底下先列出整個 SPA 專案的完整原始碼, 然後再進行說明 :



<!DOCTYPE html>
<html>
  <head>
    <title></title>
    <meta charset="utf-8">
    <meta http-equiv="cache-control" content="no-cache">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <script src="https://code.jquery.com/jquery-1.11.1.min.js"></script>
    <script src="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
    <link href="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css" rel="stylesheet">
    <style>
      table {
          color: black;
          background: #fff;
          border: 1px solid #b4b4b4;
          padding: 0;
          margin-top:10px;
          width: 100%;
          -webkit-border-radius: 8px;
          }
      table tr td {
          color: #666;
          border-bottom: 1px solid #b4b4b4;
          border-right: 1px solid #b4b4b4;
          padding: 5px 5px 5px 5px;
          background-image: -webkit-linear-gradient(top, #fdfdfd, #eee);
          }
      table tr:first-child td {
          border-top: 1px solid #b4b4b4;
          }
      table tr td:last-child {
          border-right: none;
          }
      table tr:last-child td {
          border-bottom: none;
          }
    </style>
  </head>
  <body>
    <!-- 第一頁頁面 (顯示備忘事項列表) -->
    <section data-role="page" id="page1">
      <header data-role="header" data-position="fixed">
        <h1>我的備忘錄</h1>
        <a href="#confirm_clear_dialog" id="clear_memo_btn" class="ui-btn-right" data-rel="dialog">清除</a>
      </header>
      <article data-role="content">
        <a href="#page2" data-role="button" data-icon="plus">新增</a>
        <table>
          <thead>
            <tr>
              <th>備忘事項</th>
              <th>編輯</th>
            </tr>
          </thead>
          <tbody id="memo_list">
          </tbody>
        </table>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 第二頁頁面 (新增備忘事項) -->
    <section data-role="page" id="page2">
      <header data-role="header" data-position="fixed" data-add-back-btn="true" data-back-btn-text="返回">
        <h1>新增備忘事項</h1>
      </header>
      <article data-role="content">
        <form data-ajax="false">
          <div data-role="fieldcontain">
            <label for="add_memo">備忘事項 : </label>
            <textarea id="add_memo"></textarea>
          </div>
          <input type="button" id="add_memo_btn" value="儲存" data-icon="check">
        </form>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 第三頁頁面 (編輯備忘事項) -->
    <section data-role="page" id="page3">
      <header data-role="header" data-position="fixed" data-add-back-btn="true" data-back-btn-text="返回">
        <h1>編輯備忘事項</h1>
      </header>
      <article data-role="content">
        <form data-ajax="false">
          <div data-role="fieldcontain">          
            <input type="hidden" id="edit_memo_key">
            <label for="edit_memo_value">備忘事項 : </label>
            <textarea id="edit_memo_value"></textarea>
          </div>
          <div data-role="controlgroup" data-type="horizontal">
            <a href="#" data-role='button' data-rel="back" data-icon="back" data-inline="true">取消</a>
            <a href="#confirm_remove_dialog" id="remove_memo_btn" data-role='button' data-rel="dialog" data-icon="delete" data-inline="true">刪除</a>
            <a href="#" id="update_memo_btn" data-role='button' data-icon="check" data-inline="true">更新</a>
          </div>
        </form>
      </article>
      <footer data-role="footer" data-position="fixed">
        <h3>© 2022 LF.Studio</h3>
      </footer>
    </section>
    <!-- 對話框頁面 1 (清除全部資料) -->
    <section data-role="dialog" id="confirm_clear_dialog" data-overlay-theme="b">
      <header data-role="header">
        <h1>請確認</h1>
      </header>
      <article data-role="content">
        <p>確定要清除全部資料嗎?</p>
        <div data-role="controlgroup" data-type="horizontal">
          <a href="#" data-role='button' data-icon='back'  data-inline="true" data-rel="back">取消</a>
          <a href="#" id="confirm_clear_btn" data-role='button' data-icon='check'  data-inline="true">確定</a>
        </div>
      </article>
    </section>
    <!-- 對話框頁面 2 (刪除單筆資料) -->
    <section data-role="dialog" id="confirm_remove_dialog" data-overlay-theme="b">
      <header data-role="header">
        <h1>請確認</h1>
      </header>
      <article data-role="content">
        <p>確定要刪除這筆備忘事項嗎?</p>
        <div data-role="controlgroup" data-type="horizontal">
          <a href="#" data-role='button' data-icon='back' data-inline="true" data-rel="back">取消</a>
          <a href="#" id="confirm_remove_btn" data-role='button' data-icon='check'  data-inline="true">確定</a>
        </div>
      </article>
    </section>
    <script>
      function sort_local_storage(){
        var arr=[];
        for (var i=0; i<localStorage.length; i++){
          var k=localStorage.key(i);
          var v=localStorage.getItem(k); 
          arr.push(k + "+" + v);
          }
        arr.sort();
        arr.reverse();
        return arr;
        }
      function set_edit_memo(k){
        var v=localStorage.getItem(k);
        $("#edit_memo_key").val(k);
        $("#edit_memo_value").val(v);
        render_memo_list();
        $.mobile.changePage($("#page3"));
        }
      function render_memo_list(){
        $("#memo_list").empty();
        var memo_arr=sort_local_storage();
        for (var i=0; i<memo_arr.length; i++){
          var arr=memo_arr[i].split("+");
          var k=arr[0];
          var v=arr[1];
          var item="<tr><td>" + v + "</td>" +
                   "<td style='text-align: center;'>" + 
                   "<a href='#' data-role='button' " + 
                   "data-icon='edit' data-iconpos='notext' " + 
                   "data-mini='true' data-inline='true' " + 
                   "onclick='set_edit_memo(" + '"' + k + '"' +
                   ")'>編輯</a></td></tr>";
          $(item).appendTo("#memo_list").trigger("create");
          }
        }
      $(document).one("pageinit", function(){
        if (localStorage.length == 0){
          var item="<tr><td colspan=2>無備忘事項</td></tr>";
          $(item).appendTo("#memo_list").trigger("create");
          }
        else {render_memo_list();}
        $("#add_memo_btn").on("click", function(e) {
          var k=JSON.stringify(new Date()).substr(1,19);
          var v=$("#add_memo").val();
          localStorage.setItem(k, v); 
          $("#add_memo").val("");
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#confirm_clear_btn").on("click", function(e) {
          localStorage.clear();
          $("#memo_list").empty();
          var item="<tr><td colspan=2>無備忘事項</td></tr>";
          $(item).appendTo("#memo_list").trigger("create");
          $.mobile.changePage($("#page1"));
          });
        $("#edit_memo_btn").on("click", function(e) {
          $.mobile.changePage($("#page3"));
          });
        $("#confirm_remove_btn").on("click", function(e) {
          var k=$("#edit_memo_key").val();
          localStorage.removeItem(k);
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        $("#update_memo_btn").on("click", function(e) {
          var k=$("#edit_memo_key").val();
          var v=$("#edit_memo_value").val();
          localStorage.setItem(k, v);
          render_memo_list();
          $.mobile.changePage($("#page1"));
          });
        }); 
    </script>
  </body>
</html>

上面的網頁程式原始碼中有三個 page 與兩個 dialog, 功能說明如下 : 
  • page1 :
    以表格顯示備忘記事列表 (上方有新增按鈕至 page2, 表格第二欄有編輯鈕至 page3.
  • page2 : 
    新增記事表單, 按 "儲存" 鈕將記事存入 localStorage 後自動返回 page1 記事列表.
  • page3 :
    編輯記事表單, 更改記事內容後按 "確定" 更新 localStorage 後自動返回 page1 記事列表; 按 "刪除" 則從 localStorage 刪除該資料後自動返回 page1 記事列表.
  • dialog1 : 
    詢問是否清除全部 localStorage 資料的對話框, 按 "確定" 清除 localStorage 全部資料後自動返回 page1 記事列表.
  • dialog2 :
    詢問是否刪除 localStorage 中指定 key 資料的對話框, 按 "確定" 刪除 localStorage 該筆資料後自動返回 page1 記事列表 
Javascript 程式碼部分有三個函式 :
  • sort_local_storage() :
    此函式用來將 localStorage 內儲存的資料進行排序, 由於物件的內容 (鍵值對) 是無序的, 因此沒有 sort() 方法可用來排序, 此函式的作法是用迴圈走訪 localStorage 儲存區的全部資料, 然後將 key 與 value 用 "+" 串接後存入空陣列, 再用陣列的 sort() 方法做升序排序, 最後呼叫 reverse() 做降序排序, 再將此陣列傳回. 呼叫者走訪此陣列, 將元素以 "+" 拆分即可得到排序後的 key 與 value 了, 參考 :
    # HTML5 local storage sort
  • set_edit_memo(k) : 
    此函式是使用者在按下記事列表中的編輯鈕後被呼叫, 目的是將傳進來的鍵 (k) 與查得之值設定到 page2 的編輯欄位中 (key 用隱藏欄位儲存), 以便稍後切換到 page2 時會顯示被編輯的記事內容. 
  • render_memo_list() : 
    此函式會先呼叫 sort_local_storage() 取得排序後的資料陣列, 然後在走訪此陣列的迴圈中以 "+" 為界拆解出 key 與 value, 用來產生表格內容 (tbody 元素的子元素 tr 與 td), 然後呼叫 appendTo() 方法將這些內容掛到 tbody 底下, 接著呼叫 trigger("create") 方法更新 DOM 樹. 每次做新增與刪除這兩種改變儲存區內容的動作後都會先呼叫此函式來更新內容, 再切換到 page1 去. 
其他在 pageinit 事件內部的程式碼都是用 jQuery 來處理元件觸發的事件. 結果如下 : 

剛使用此網頁 App 時因為 localStorage 為空, 故記事列表亦為空 :




按 "新增" 鈕會切換到記事輸入頁 (page2), 填寫後按 "確定" 會將此資料存入 localStorage 儲存區, 並轉回記事列表頁 (page1) : 





繼續輸入第二筆備忘記事 : 





按下第二欄的編輯按鈕會切換到 page3, 可對記事內容進行修改, 按 "確定" 會更新 localStorage 內的資料, 然後轉回記事列表頁 (page1) : 





若在編輯頁面按下 "刪除" 鈕, 則會跳出確認對話框 (dialog2), 按 "確定" 會從 localStorage 內刪除該筆資料, 然後轉到記事列表 (page1) 頁面 : 





按頁面右上角的 "清除" 鈕也會跳出對話框 (dialog1), 詢問是否要清除全部 localStorage 資料, 按 "確定" 會刪除全部資料後跳轉 page1, 這時就會回復到空的記事列表了 : 




感覺似乎與後端資料庫的 CRUD 操作一模一樣, 但其實是在存取本機客戶端的 localStorage 而已, 資料並沒有透過網路傳輸, 亦即完全是是離線作業, 非常適合用來儲存個人資料. 


二. 將 jQuery Mobile 行動網頁程式轉成 Android App (apk 檔) : 

完成 jQuery Mobile 網頁應用程式設計後即可利用 appsgeyser 這個免費的線上轉換服務將行動網頁 App 轉成 Android 的 App, 其網址為 :


作法參考 :


為了讓 appgeyser 能讀取網頁原始檔, 要先將上面的 jQuery Mobile 網頁放到一個伺服器上, 最方便的是靜態網頁伺服器 GitHub, 參考 : 


然後還要幫 App 準備一個 icon, 我搜尋 "free memo icon" 找到一個適合的免費圖檔來修改, appgeyser 要求圖檔大小不可超過 512*512. 準備好就可以按下 "CREATE APP NOW" 鈕了 : 




在 "Website URL" 欄填入行動網頁 App 的網址後按 "NEXT" : 




在 "APP NAME" 欄填入 App 名稱 (中英文皆可, 不要太長), 按 "NEXT" :




點選 "Custom icon", 再按右邊的 "Upload 512*512" 上傳圖檔 : 



在彈出頁面中移動底下的滑桿選擇範圍, 然後按底下的 "Crop" 鈕裁切並返回設定頁面 : 




按 "NEXT" 鈕 :




按 "CREATE" 鈕進行 Apk 檔製作, 這時會要求註冊帳號 (過程略) : 




Apk 檔建立完成顯示如下頁面, 按最上方的下載按鈕即可下載 apk 檔 : 

 


也可以用手機掃 QR code 下載或郵寄到註冊信箱 : 






下載後開啟 apk 檔安裝 : 






彈出安全性提示, 選擇 "仍要安裝" 與 "不傳送" : 






完成安裝後桌面就出現此 App 了, 點選執行結果與上面行動網頁 App 完全一樣 : 





OK, 總算完工啦!

參考資料 :