星期一, 11月 02, 2026

[Line] Flex Message - 模擬器

Line 訊息只能傳送單一類型內容,例如文字、圖片或影片,但 Flex Message 基於 CSS Flexible Box (CSS Flexbox) 能提供更豐富且具互動性的版面配置,官方有提供模擬器,可以製作 Flex Message 並發送測試,該筆記會透過模擬器來發送 Flex Message

Flex Message Simulator 介紹

使用 Line 帳號登錄模擬器後,該畫面是進入預設版面


模擬器 Layout 分為三大區域,由左至右分別為預覽畫面、訊息控件樹狀圖、控件屬性,右上角四個按鈕功能分別為
  • New:建立新的空 Flex Message,只有 bubble 和 carousel 兩個選項
  • Showcase:使用內建 Flex Message 範本
  • Send:將設計好的 Flex Message 傳送至 Line 進行測試,需要先把 Flex Message Simulator 加入好友才能進行發送
  • View as JSON:查看設計 Flex Message json,當 Flex Message 設計到一半時可以先透過該功能把 Flex Message json 複製出來,要繼續編輯時再複製回去並按右下角 Apply 按鈕就可以回復

Flex Message 控件一覽圖

這張官方圖簡單明瞭
製作並發送 Flex Message

使用 Receipt 範例來建立該 Flex Message,只用上 body 區塊與 vertical 垂直排列
Flex Message json 檔案
{
  "type": "bubble",
  "body": {
    "type": "box",
    "layout": "vertical",
    "contents": [
      {
        "type": "text",
        "text": "Flex Message Simulator",
        "weight": "bold",
        "color": "#1DB446",
        "size": "xs"
      },
      {
        "type": "text",
        "text": "Hello Flex Message",
        "weight": "bold",
        "size": "xl",
        "color": "#1E293B"
      },
      {
        "type": "text",
        "text": "從模擬器發出的第一張 Flex Message,這張卡片只用了最基礎的 body 區塊與 vertical 垂直排列容器",
        "size": "sm",
        "color": "#64748B",
        "wrap": true,
        "lineSpacing": "4px"
      }
    ]
  }
}

Flex Message 限制

同一則 Flex 訊息可能會因接收裝置的環境不同,而呈現不同的顯示效果。裝置的作業系統、LINE 版本、螢幕解析度、語言設定及字型,都可能影響訊息的顯示效果

星期二, 10月 20, 2026

[GAS] 拆活動時段

報名活動使用 Google Forms 時,發現時段是以下列方式呈現,直覺是資料這樣儲存,是要怎麼統計,練習一下在 Google Sheet 內把時段拆開並進行統計

Google Form 上的選單選項
各時段示意圖
設計流程圖
拆解活動時段 gs Code

重點紀錄
  • 該 GAS 是綁定在 Google Sheets 上,而不是 Google Forms
  • gs Code 抓取 Google Sheet 部分,為了方便手動執行測試,會判斷是從 Google Form 還是 GAS 內直接執行的
  • Range.insertCheckboxes() 是指把指定範圍內的 cell 變成 checkbox 並設定勾選值
/**
 * Google Forms 提交後,自動拆解 A、B、C 時段。
 *
 * C 欄:原始報名選項
 * D 欄:A 時段
 * E 欄:B 時段
 * F 欄:C 時段
 */
function onFormSubmit(e) {

  // 從試算表取得工作表
  const sheet = e && e.range
    ? e.range.getSheet()
    : SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Form_Responses");

  if (!sheet) {
    throw new Error("找不到 Form_Responses 工作表。");
  }

  // 表單提交:使用實際提交列
  // 手動執行:使用最後一列
  const row = e && e.range
    ? e.range.getRow()
    : sheet.getLastRow();

  if (row <= 1) {
    throw new Error("目前沒有可處理的報名資料。");
  }

  // 讀取 C 欄報名選項
  const selectedValue = sheet.getRange(row, 3).getDisplayValue();

  // 解析報名選項
  // 例如 A + C (08:00 - 13:00) → ["A", "C"]
  const selectedGroups = selectedValue
    .split("(")[0]
    .split("+")
    .map(group => group.trim());

  // 基本時段
  const groups = ["A", "B", "C"];

  // 產生 CheckBox 勾選狀態
  const checkboxValues = groups.map(group =>
    selectedGroups.includes(group)
  );

  // D、E、F 欄
  const range = sheet.getRange(row, 4, 1, 3);

  // 建立 CheckBox
  range.insertCheckboxes();

  // 寫入勾選狀態
  range.setValues([checkboxValues]);
}
設定觸發條件

活動類型:提取表單時,在 以事件為準的觸發條件 內說明
An installable form submit trigger runs when a user responds to a form,There are two versions of the form-submit trigger
  • one for Google Forms itself
  • one for Sheets if the form submits to a spreadsheet
該筆記是屬於後者,意思是當 Google Sheets 接收到從 Google Forms 送來資料時會觸發,而該 Blog 筆記 - [GAS] 自行定義回覆 Email,則是屬於前者

因為 function 名稱都慣性取名為 onFormSubmit,所以誤會兩件事情
  • 直覺 GAS 是綁定在 Goole Forms 上,但就該筆記並不是
  • 以為跟簡單觸發條件 onOpen、doGet 一樣會自動觸發,不需要手動建立
統計公式設定

時段拆解開後,在 Google Sheets 內另外開一個 Sheet 來當成統計頁面,透過設定公式 CountIf() 來進行統計,小活動用最簡單方式來達成就好
=COUNTIF(Data!D2:D,TRUE)
=COUNTIF(Data!E2:E,TRUE)
=COUNTIF(Data!F2:F,TRUE)


實際執行

星期六, 10月 17, 2026

[SQL] 資料表值參數 - DataTable 參數欄位順序

使用 [SQL] 資料表值參數 (Table Value Parameters) 時拋出字串要轉型為整數 SQL Exception,這才發現原來 DataTable 參數欄位順序要搭配 TVP 參數順序,兩者必須手動維持順序一致,沒有辦法自動對應
使用者定義資料表類型 (User-defined Table Type)
CREATE TYPE [dbo].[UserDefinedTableType] AS TABLE(
	Col1 char(4) NOT NULL,
	Col2 char(16) NOT NULL,
	Col3 int NOT NULL
)
該 case 是使用 FastMember ObjectReader 把 List<T> 轉成 DataTable,所以有兩種處理方式
  1. 事先定義欄位順序:在 class 上指定 FastMember.OrdinalAttribute 定義轉換後欄位順序,詳見 [C#] FastMember-ObjectReader
  2. 事後調整欄位順序:產生 DataTable 後,透過 DataColumn.SetOrdinal() 調整 DataTable 欄位順序
// ToDataTable() 則是封裝好的 FastMember ObjectReader 轉換功能
DataTable dt = List<T>.ToDataTable();
dt.Columns[Col1].SetOrdinal(0);
dt.Columns[Col2].SetOrdinal(1);
dt.Columns[Col3].SetOrdinal(2);

星期五, 10月 16, 2026

[SQL] SQL Profile 側錄 Exception

C# AP 端開發時發生 SQL Exception,在本地端開啟 Profile 側錄語法時沒有看見 SQL Exception 資訊,發現使用標準範本的話要特別去開啟 Errors and Warmings 內相關事件才會記錄

C# AP 端拋出 SQL Excpetion
該 case 在 Profile 內開啟 Exception 和 User Error Message 就行
Profile 側錄結果

星期二, 10月 13, 2026

[LINQ] ToLookup

ToLookup 用來將集合轉成一個「一對多」的字典結構(ILookup<TKey, TElement>)

主要特點
  • 一對多對應(One-to-Many):一個鍵(Key)可以對應到多個值(Collection of values),與 ToDictionary(一對一)不同
  • 立即執行(Eager Execution):呼叫 ToLookup() 時會立即計算並產生結果,這點跟 GroupBy()延遲執行 (Deffered Execution) 不同
  • 唯讀結構:產生的 ILookup 是唯讀的,無法修改
  • 安全存取:如果用 Key 去取值,就算該 Key 不存在,它也會回傳一個空的集合而不是丟出例外錯誤(KeyNotFoundException)
簡易範例
namespace ToLookupSample
{
    internal class Program
    {
        static void Main(string[] args)
        {
            var books = new List<Book>
            {
                new Book { Title = "C# 實戰", Category = "CSharp", PublishYear = 2025 },
                new Book { Title = "設計模式", Category = "CSharp", PublishYear = 2023 },
                new Book { Title = "MS SQL TSQL 學習手冊", Category = "SQL", PublishYear = 2020 },
                new Book { Title = "MS SQL 效能調校", Category = "SQL", PublishYear = 2025 },
                new Book { Title = "MS SQL 索引深入淺出", Category = "SQL", PublishYear = 2025 },
                new Book { Title = "世界歷史", Category = "History", PublishYear = 1981 }
            };

            // 1. 建立 ILookup:以 Category 為 Key
            ILookup<string, Book> bookLookup = books.ToLookup(b => b.Category);

            // 2. 透過索引子 [Key] 查詢存在的書籍分類
            IEnumerable<Book> sql = bookLookup["SQL"];
            Console.WriteLine($"SQL 書籍:{sql.Count()}");
            foreach (Book book in sql)
                Console.WriteLine($"     ----- {book.Title}");

            // 3. 查詢不存在的書籍分類:回傳空集合,不會拋出 Exception
            IEnumerable<Book> ai = bookLookup["AI"];
            Console.WriteLine($"是否有 AI 分類書籍:{ai.Any()}");

            // 4. 檢查某個 Key 是否存在
            bool hasHistory = bookLookup.Contains("History");
            Console.WriteLine($"是否有 History 分類書籍:{hasHistory}");

            // 5. 忽略大小寫
            ILookup<string, string> books4OrdinalIgnoreCase = books.ToLookup(
                keySelector: b => b.Category,
                elementSelector: b => b.Title,
                comparer: StringComparer.OrdinalIgnoreCase // 忽略大小寫
            );

            IEnumerable<string> csharp = books4OrdinalIgnoreCase["csharp"];
            Console.WriteLine($"C# 書籍 - 不區分大小寫");
            foreach (string title in csharp)
                Console.WriteLine($"     ----- {title}");

            // 6. Key 使用 ValueTuple (Category, PublishYear) 作為複合 Key
            var booksValueTuple = books.ToLookup(b => (
                Category: b.Category,
                Year: b.PublishYear
            ));

            var targetKey = ("SQL", 2025);
            var matchedBooks = booksValueTuple[targetKey].ToList();

            Console.WriteLine($"2005 出版 SQL 書籍 - 複合 Key");
            foreach (Book book in matchedBooks)
                Console.WriteLine($"     ----- {book.Title}");
        }
    }

    public class Book
    {
        public string Title { get; set; }
        public string Category { get; set; }
        public int PublishYear { get; set; }
    }
}