

<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>node.js筆記 &#8211; Max的程式語言筆記</title>
	<atom:link href="https://stackoverflow.max-everyday.com/category/node-js/feed/" rel="self" type="application/rss+xml" />
	<link>https://stackoverflow.max-everyday.com</link>
	<description>我要當一個豬頭，快樂過每一天</description>
	<lastBuildDate>Sun, 22 Mar 2026 15:16:11 +0000</lastBuildDate>
	<language>zh-TW</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</generator>

<image>
	<url>https://stackoverflow.max-everyday.com/wp-content/uploads/2017/02/max-stackoverflow-256.png</url>
	<title>node.js筆記 &#8211; Max的程式語言筆記</title>
	<link>https://stackoverflow.max-everyday.com</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Claude Code Channels 挑戰 OpenClaw 龍蝦</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/claude-code-channels-openclaw/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/claude-code-channels-openclaw/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Sun, 22 Mar 2026 15:16:10 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8068</guid>

					<description><![CDATA[▋ Claude Code 新功能：Channe...]]></description>
										<content:encoded><![CDATA[
<p>▋ Claude Code 新功能：Channels 實測與 OpenClaw 差異分析</p>



<p>Claude Code 剛發布了全新的 Channels 功能，簡單來說就是透過 MCP 協定將事件傳出，讓你直接用 Telegram 或 Discord 來控管 Claude Code。這聽起來跟 OpenClaw 非常相似，但我實測後發現兩者還是有一些細微的架構差異。</p>



<p>首先，如果你原本已經有一套龍蝦的配置，目前是無法直接無痛移轉到 Claude Code 的，畢竟底層架構不同。另外，Claude Code 的 Channels 目前缺乏 Heartbeat 機制，這意味著如果 Claude Code 當下沒有在執行任務，它就不會回應你的訊息。這點在遠端喚醒的體驗上，跟 OpenClaw 還是有落差。</p>



<p>一個實際的應用場景是：你可以建立一個 Channels 來幫你重啟龍蝦。因為每當我們修改了 .openclaw.json 設定後，龍蝦必須重啟才能生效，這時候透過 Channels 來下指令就非常方便。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 安裝與環境設定</p>



<p>要體驗這個功能，必須先將 Claude Code 升級到 2.1.80 以上版本。進入介面後執行安裝指令：</p>



<p>/plugin install telegram@claude-plugins-official</p>



<p>我在安裝時遇到 plugin not found 的錯誤，這通常是因為舊有的 marketplace 資訊沒更新。解法是進入 /plugin 選單，手動將 claude-plugins-official 更新到最新版，Telegram 外掛才會出現。注意更新完後，單純執行 /reload-plugins 可能沒用，建議直接重啟 Claude Code。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ Telegram 連線實作</p>



<p>實際設定流程與官方文件略有出入。設定 Token 時，應使用 /configure 指令而非 /telegram:configure：</p>



<p>/configure &lt;你的Token&gt;</p>



<p>接著在本地啟動服務：</p>



<p>claude &#8211;channels plugin:telegram@claude-plugins-official</p>



<p>當你從 Telegram Bot 發送訊息給 Claude Code 時，系統會要求配對。回到 Claude Code 介面輸入 Telegram 上顯示的配對碼：</p>



<p>/telegram:access pair xxxxx</p>



<p>這個流程與當初設定龍蝦非常像。除了 Telegram，官方也提供 Discord 插件，設定邏輯大同小異。如果你想先在本地測試，官方還有一個 fakechat demo，可以在 localhost 模擬聊天 UI，不需連接外部服務。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 使用者指南 vs 開發者參考</p>



<p>官方釋出了兩份文件。第一篇是針對一般使用者的操作手冊，涵蓋安裝、設定以及企業級的安全控管。第二篇則針對開發者，解釋了 Channel 的本質就是一個 MCP Server。只要在 capabilities 中宣告 claude/channel，並透過 notifications 運作，就能將事件推入 session。</p>



<p>這代表應用空間很大。你可以寫一個 Webhook Receiver，當 CI Pipeline 跑完時自動推通知給 Claude Code，或是讓系統監控警報直接觸發 Claude 自動處理問題。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 安全機制與限制</p>



<p>Channels 區分為單向推播（如 CI 通知）與雙向對話（如 Telegram 橋接）。在安全性方面，每個 Channel 都有 sender allowlist，只有經過配對的使用者 ID 才能傳送訊息，這能有效防止未經授權的指令變成 Prompt Injection 的漏洞。</p>



<p>目前 Channels 處於 Research Preview 階段，若要測試自己開發的 Channel，啟動時須加上 &#8211;dangerously-load-development-channels 參數。此外，Team 與 Enterprise 方案預設是關閉此功能的，需由管理員手動開啟，個人版則無此限制。</p>



<p>大家目前有在用 OpenClaw 或其他遠端控制方案嗎？你認為 Channels 未來能完全取代龍蝦，還是兩者會並存發展？</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>如何撰寫自定義的 MCP Channel 來對接特定的監控系統</p>



<p>那麼，讓我們來看看如果要開發一個自定義的 Channel（例如對接公司的內部監控系統或特殊的 Webhook），在技術架構上需要注意哪些核心細節：</p>



<p>▋ 自定義 Channel 的運作邏輯</p>



<p>自定義 Channel 本質上是一個 MCP Server。開發時最關鍵的步驟是在 Server 的 capabilities 中宣告支援 claude/channel。</p>



<p>這與一般的 MCP Tool 不同，一般的 Tool 是由 Claude 主動調用，而 Channel 則是讓外部事件（如監控警報、CI 失敗通知）主動推播進 Claude 的對話 Session 中。當事件發生時，你的 Server 會透過 JSON-RPC 的 notifications/claude/channel 機制發送通知，這時候 Claude 就會接收到訊息並開始根據內容執行任務。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 單向推播與雙向互動的實作差異</p>



<p>如果你只需要讓 Claude 接收通知（單向），實作相對簡單。只需要確保 Webhook 接收到資料後，正確轉換成 Claude 能理解的文字格式並推送到 Session 即可。這類應用很適合放在自動化部署流程中，一旦部署失敗，Claude 收到通知後可以直接去檢查日誌。</p>



<p>如果是要像 Telegram 這樣可以來回對話（雙向），則需要實作 reply tool。當 Claude 處理完訊息並產生回覆時，它會調用這個 tool，你的 Server 接收到回覆後，再透過對應的 API（如 Telegram Bot API）把訊息傳回給使用者。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 開發環境的啟動指令</p>



<p>在開發階段，因為自定義的 Channel 尚未經過官方驗證，直接執行啟動指令會被擋下來。你必須在啟動 Claude Code 時加上特殊的開發標籤：</p>



<p>claude &#8211;dangerously-load-development-channels &lt;你的MCP伺服器路徑或指令&gt;</p>



<p>這會跳過安全檢查，允許 Claude 載入非官方提供的 Channel 插件。這對於企業內部開發私有的運維自動化工具非常有用。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 安全性與 Allowlist 設計</p>



<p>安全性是開發 Channel 時最容易被忽略的一環。因為 Channel 是一個可以讓外部訊息進入系統的入口，如果不加限制，任何人只要知道你的 Endpoint 都能對你的 Claude 下指令。</p>



<p>官方的設計建議是在 Server 端實作配對機制（Pairing）。當新的使用者 ID 傳送訊息時，Server 應該先比對內部的 Allowlist。如果是未知的 ID，則應在 Claude 端顯示一個配對碼，要求使用者手動輸入完成授權。這能有效防止透過外部通訊軟體進行的 Prompt Injection 攻擊。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 未來的擴充可能性</p>



<p>既然 Channel 是基於 MCP，這代表它可以跟其他的 MCP Tool 產生聯動。想像一下，你的監控系統發現資料庫壓力過大，透過 Channel 發通知給 Claude，Claude 收到後自動調用 SQL Tool 進去檢查慢查詢，最後再透過 Channel 回報處理進度給你的手機。這種全自動化的運維流程，正是 Channels 功能最迷人的地方。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>寫一個簡單的 Python 或 Node.js 範例，示範如何建立一個基礎的 MCP Channel Server </p>



<p>那麼，我用 Node.js 配合 <code>@modelcontextprotocol/sdk</code> 來示範一個最基礎的 MCP Channel Server 結構。這個範例會展示如何宣告 <code>claude/channel</code> 能力，並模擬一個外部事件推播到 Claude Code。</p>



<p>▋ 基礎環境準備</p>



<p>首先，你需要安裝 MCP 的官方 SDK。在你的專案目錄執行：</p>



<p>npm install @modelcontextprotocol/sdk</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ MCP Channel Server 範例程式碼</p>



<p>這個簡單的 Server 會建立一個 stdio 傳輸介面。核心在於 <code>capabilities</code> 的宣告，以及使用 <code>notification</code> 把訊息擠進 Claude 的 Session。</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { 
  CallToolRequestSchema, 
  ListToolsRequestSchema,
  NotificationSchema 
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  { name: "my-custom-channel", version: "1.0.0" },
  { 
    capabilities: { 
      // 關鍵：告訴 Claude Code 這是一個 Channel
      "claude/channel": {} 
    } 
  }
);

// 模擬一個外部事件（例如來自 Webhook 或 監控系統）
function sendExternalAlert(message) {
  server.notification({
    method: "notifications/claude/channel/message",
    params: {
      channelId: "my-monitor-01",
      message: {
        role: "user",
        content: &#91;{ type: "text", text: `&#91;警報通知]: ${message}` }]
      },
      metadata: {
        senderId: "system-monitor",
        isAuthorized: true // 這裡應配合你的 Allowlist 邏輯
      }
    }
  });
}

const transport = new StdioServerTransport();
await server.connect(transport);

// 啟動 10 秒後模擬發送一個通知
setTimeout(() =&gt; {
  sendExternalAlert("伺服器 CPU 使用率過高，請檢查進程。");
}, 10000);
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 如何在 Claude Code 中測試</p>



<p>開發完畢後，你不能直接用一般的方式載入，必須使用開發者旗標。假設你的腳本名稱是 <code>server.js</code>：</p>



<p>claude &#8211;dangerously-load-development-channels &#8220;node server.js&#8221;</p>



<p>啟動後，Claude Code 會建立一個連線 Session。當 10 秒過後，<code>sendExternalAlert</code> 被觸發時，你會在 Claude Code 的終端機介面直接看到該則訊息跳出，Claude 接著會自動根據這條訊息開始思考並嘗試修復問題。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 實作雙向對話的重點</p>



<p>如果你希望 Claude 能「回覆」到你的外部系統（例如回傳訊息到 Slack），你必須在 <code>ListToolsRequestSchema</code> 中註冊一個名為 <code>claude/channel/reply</code> 的工具。</p>



<p>當 Claude 想要回話時，它會自動呼叫這個 Tool。你的 Server 收到 call 後，再把 <code>params</code> 裡面的文字內容打到你的轉接層（Adapter）即可完成閉環。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 開發建議與偵錯</p>



<p>因為 Channel 涉及到非同步的事件推播，偵錯時建議觀察 Claude Code 的日誌輸出。另外，記得處理 <code>pairing</code> 邏輯，雖然範例中直接給了 <code>isAuthorized: true</code>，但在生產環境中，你必須驗證 <code>senderId</code> 是否在你的資料庫名單內，否則任何能接觸到你 MCP Server 的來源都能控制你的開發環境。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>針對「雙向回覆」的 Tool 實作細節，提供更具體的程式碼片段</p>



<p>好的，那我們接著看「雙向對話」的核心實作。要讓 Claude 能把話傳回給你的外部系統（例如傳回 Slack 或手機），你的 MCP Server 必須實作一個特定的 Tool。</p>



<p>▋ 實作雙向回覆的核心：claude/channel/reply</p>



<p>在 MCP 的規範中，當 Claude 想要回應一個 Channel 訊息時，它會尋找並呼叫一個名為 <code>claude/channel/reply</code> 的工具。你需要在 Server 的工具清單中註冊它。</p>



<p>以下是擴充後的 Node.js 程式碼片段：</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>// 在原本的工具定義中加入
server.setRequestHandler(ListToolsRequestSchema, async () =&gt; {
  return {
    tools: &#91;
      {
        name: "claude/channel/reply",
        description: "將 Claude 的回覆傳送回外部通訊管道",
        inputSchema: {
          type: "object",
          properties: {
            channelId: { type: "string" },
            message: { type: "string" }
          },
          required: &#91;"channelId", "message"]
        }
      }
    ]
  };
});

// 處理 Claude 的回覆請求
server.setRequestHandler(CallToolRequestSchema, async (request) =&gt; {
  if (request.params.name === "claude/channel/reply") {
    const { channelId, message } = request.params.arguments;
    
    // 這裡實作你發送到外部 API 的邏輯
    console.log(`&#91;發送回覆至 ${channelId}]: ${message}`);
    
    // 呼叫例如 Telegram Bot API 或 Slack Webhook
    // await sendToExternalPlatform(channelId, message);

    return {
      content: &#91;{ type: "text", text: "訊息已成功傳回外部系統。" }]
    };
  }
  throw new Error("找不到對應的工具");
});
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 實作流程的關鍵點</p>



<p>當這套機制跑起來後，整個對話閉環會像這樣運作：</p>



<ol start="1" class="wp-block-list">
<li><strong>外部事件觸發</strong>：你的監控系統發現異常，呼叫你的 MCP Server。</li>



<li><strong>Notification 推播</strong>：Server 發送 <code>notifications/claude/channel/message</code> 給 Claude Code。</li>



<li><strong>Claude 思考</strong>：Claude 在終端機看到訊息，分析問題後決定回報處理狀況。</li>



<li><strong>調用 Reply Tool</strong>：Claude 主動執行 <code>claude/channel/reply</code>，把它的回覆文字塞進 <code>message</code> 參數。</li>



<li><strong>轉接傳回</strong>：你的 Server 接收到這個 call，透過網路請求把文字傳回給原本的發話者。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 關於開發安全性（Authentication）</p>



<p>在實作雙向對話時，<code>channelId</code> 的管理非常重要。你必須在 Server 內部維護一個對照表（例如 SQLite 或簡單的 Map），紀錄 <code>channelId</code> 對應到外部系統的哪個對話視窗（Chat ID）或使用者。</p>



<p>這就是為什麼官方範例會需要「配對（Pairing）」的原因。第一次連線時，Server 會產生一個隨機碼，請你在 Claude Code 輸入，這樣 Server 才能把你的「Claude Session」與你的「Telegram 帳號」綁定在一起。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>▋ 下一步建議</p>



<p>你可以試著將這個 Server 包裝成一個 Docker Container，然後部署在你的 VPS 上。只要透過 SSH 隧道（SSH Tunnel）或是暴露一個特定的 Port，就能讓本地運行的 Claude Code 連接上這個遠端的「通訊橋接器」。</p>



<p></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/claude-code-channels-openclaw/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>docker-compose.yaml 與 dockerfile 差別</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/docker-compose-yaml-dockerfile/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/docker-compose-yaml-dockerfile/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Tue, 17 Mar 2026 05:19:27 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<category><![CDATA[docker]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7979</guid>

					<description><![CDATA[這兩者雖然都跟 Docker 有關，但分工非常明...]]></description>
										<content:encoded><![CDATA[
<p>這兩者雖然都跟 Docker 有關，但分工非常明確。簡單來說：Dockerfile 負責<strong>做出一台電腦（映像檔）</strong>，而 docker-compose.yaml 負責<strong>把多台電腦連成一個系統</strong>。</p>



<h2 class="wp-block-heading">Dockerfile：單一機器的製作說明書</h2>



<p>Dockerfile 是一個純文字檔，裡面記錄了如何從無到有建構出一個環境。例如你要把一個 Node.js 專案打包，Dockerfile 會寫：</p>



<ol start="1" class="wp-block-list">
<li>從哪個作業系統開始（例如 Ubuntu 或輕量化的 Alpine）。</li>



<li>要安裝哪些軟體（例如 Node.js、ffmpeg）。</li>



<li>要把哪些程式碼檔案複製進去。</li>



<li>最後啟動程式的指令是什麼。</li>
</ol>



<p>它的產出是一個 <strong>Image（映像檔）</strong>。這就像是你把整台裝好環境的電腦做成了「壓縮檔」，不管拿到誰的電腦上執行，環境都會一模一樣。</p>



<h2 class="wp-block-heading">docker-compose.yaml：整個系統的配置藍圖</h2>



<p>當你的專案變大，不再只有一個 Node.js 程式，還需要資料庫（MySQL）、快取（Redis）或網頁伺服器（Nginx）時，如果你一台一台手動啟動會非常痛苦。這時候就需要 <code>docker-compose.yaml</code>。</p>



<p>它會定義：</p>



<ol start="1" class="wp-block-list">
<li><strong>服務 (Services)：</strong> 這裡有 Node.js 程式、那裡有資料庫。</li>



<li><strong>網路 (Networks)：</strong> 讓這些服務可以互相溝通（例如讓 Node.js 找到資料庫）。</li>



<li><strong>磁碟 (Volumes)：</strong> 讓資料庫的資料可以保存在硬碟裡，不會因為容器關掉就消失。</li>
</ol>



<p>它的作用是<strong>管理多個容器</strong>。只要執行一個 <code>docker-compose up</code> 指令，它就會根據這份藍圖，把所有需要的電腦（容器）通通開起來並連上網路。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">兩者的差異對照表</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><td><strong>特性</strong></td><td><strong>Dockerfile</strong></td><td><strong>docker-compose.yaml</strong></td></tr></thead><tbody><tr><td><strong>核心目標</strong></td><td>建立單個 Image（映像檔）</td><td>啟動並管理多個 Container（容器）</td></tr><tr><td><strong>關注點</strong></td><td>軟體安裝、環境變數、程式啟動</td><td>服務之間的連線、連接埠對應、資料保存</td></tr><tr><td><strong>指令範例</strong></td><td><code>docker build</code></td><td><code>docker-compose up</code></td></tr><tr><td><strong>比喻</strong></td><td>某一道菜的食譜</td><td>整桌酒席的菜單與座位安排</td></tr></tbody></table></figure>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">實際開發中的搭配</h2>



<p>通常你會為你的 React 或 Node.js 專案寫一個 Dockerfile，確保程式能在任何地方跑起來。然後再寫一個 <code>docker-compose.yaml</code>，把你的程式跟資料庫串在一起。這樣你在換新電腦開發時，只要下一個指令，整套開發環境（包含資料庫）就會在幾秒鐘內架設完成。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>一個同時包含 Node.js 跟資料庫的 <code>docker-compose.yaml</code> 範例，了解它們是怎麼互相溝通的</p>



<p>這是一個常見的開發環境組合：一個 Node.js 後端程式配上一個 MariaDB 資料庫。透過 <code>docker-compose.yaml</code>，你不需要在電腦上安裝資料庫軟體，Docker 會自動幫你處理好。</p>



<h2 class="wp-block-heading">專案配置範例</h2>



<p>假設你的專案資料夾裡已經有一個寫好的 <code>Dockerfile</code>（用來跑 Node.js），接著你會在同一個地方建立這份 <code>docker-compose.yaml</code>：</p>



<p>YAML</p>



<pre class="wp-block-code"><code>version: '3.8'

services:
  # 後端程式服務
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DB_HOST=db
      - DB_USER=max
      - DB_PASSWORD=secret
    depends_on:
      - db

  # 資料庫服務
  db:
    image: mariadb:10.6
    restart: always
    environment:
      - MARIADB_ROOT_PASSWORD=root_pass
      - MARIADB_DATABASE=my_project
      - MARIADB_USER=max
      - MARIADB_PASSWORD=secret
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:
</code></pre>



<h2 class="wp-block-heading">關鍵設定解析</h2>



<p>在上面的 YAML 檔中，有幾個非常聰明的設計：</p>



<ul class="wp-block-list">
<li><strong>depends_on：</strong> 這告訴 Docker 必須先讓資料庫跑起來，再啟動 Node.js 程式，避免程式因為找不到資料庫而當掉。</li>



<li><strong>服務名稱即網址：</strong> 在 Node.js 的程式碼裡，你連接資料庫的網址不需要寫 IP，直接寫 <code>db</code>（就是服務名稱）即可。Docker 內部有一套自動電話簿，會幫你轉接到正確的容器。</li>



<li><strong>Volumes (資料持久化)：</strong> 資料庫的資料預設是存在容器裡的虛擬硬碟，如果容器刪除資料就沒了。我們透過 <code>volumes</code> 把容器裡的資料夾對應到你電腦的實體硬碟，這樣即便重開機，資料也還會在。</li>
</ul>



<h2 class="wp-block-heading">啟動與管理指令</h2>



<p>寫好這份檔案後，你只需要在終端機輸入：</p>



<p>Bash</p>



<pre class="wp-block-code"><code># 啟動所有服務（加上 -d 會在背景執行）
docker-compose up -d

# 查看目前有哪些容器在跑
docker-compose ps

# 停止並移除所有容器（但保留資料庫內容）
docker-compose down
</code></pre>



<p>這套流程對於全端工程師來說非常強大，因為你可以確保「我的電腦跑得起來，客戶的伺服器也一定跑得起來」。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>case study:</p>



<pre class="wp-block-preformatted">react 寫的web, 有 attachment 要放到 azure 的 blob storage, database 要使用 azure sql server, docker-compose 如何寫, 在本地端 localhost 的 docker 如何進行功能的驗證與測試.</pre>



<p>在本地端模擬這一套雲端架構，核心做法是利用 Docker 容器來運行資料庫，並使用模擬器來替代雲端存儲空間。</p>



<h2 class="wp-block-heading">專案架構與 Dockerfile</h2>



<p>首先，你需要為 React 前端與 Node.js 後端（用於處理 Azure Blob 與資料庫連線）準備環境。</p>



<p>Dockerfile</p>



<pre class="wp-block-code"><code># 後端 Dockerfile 範例
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD &#91;"npm", "run", "dev"]
</code></pre>



<h2 class="wp-block-heading">docker-compose.yaml 配置</h2>



<p>為了在 localhost 驗證 Azure 功能，我們引入 Azurite 作為 Blob Storage 的模擬器，並使用 Microsoft 提供的 SQL Server 映像檔。</p>



<p>YAML</p>



<pre class="wp-block-code"><code>version: '3.8'

services:
  # 後端 API
  server:
    build: ./server
    ports:
      - "3000:3000"
    environment:
      - AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xBDfhYFl6480lEy9XRSdg57xvE4NMpVq0n7Slsraw==;BlobEndpoint=http://azurite:10001/devstoreaccount1;
      - DB_SERVER=sqlserver
      - DB_USER=SA
      - DB_PASSWORD=YourStrongPassword123
    depends_on:
      - azurite
      - sqlserver

  # Azure Blob 模擬器
  azurite:
    image: mcr.microsoft.com/azure-storage/azurite
    ports:
      - "10001:10001"

  # Azure SQL 模擬器 (MS SQL Server)
  sqlserver:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - MSSQL_SA_PASSWORD=YourStrongPassword123
    ports:
      - "1433:1433"
</code></pre>



<h2 class="wp-block-heading">本地端功能驗證與測試</h2>



<p>部署好容器後，你可以透過以下方式驗證功能是否正常：</p>



<ol start="1" class="wp-block-list">
<li>資料庫連線驗證你可以使用 VS Code 的 SQL Server 擴充功能，連線到 localhost:1433。帳號為 SA，密碼為你在 YAML 檔設定的那組。如果能成功建立資料表，代表後端程式碼中的 mssql 套件也能正常運作。</li>



<li>檔案上傳驗證 (Blob)當 React 送出附件到後端，後端透過 Azure SDK 上傳至 Azurite 時，你可以安裝 Azure Storage Explorer。這個工具可以連線到本地的 Azurite，讓你直接看到檔案是否有成功被放入容器（Container）中。</li>



<li>環境變數切換在本地測試時，連線字串指向的是 azurite 與 localhost；當你部署到 Azure 時，只需要透過環境變數將連線字串換成真正的 Azure Blob Endpoint 與 Azure SQL 位置，程式碼完全不需要修改。</li>



<li>網路偵錯因為 React 是在瀏覽器跑，Node.js 是在容器跑。請確保 React 是呼叫 localhost:3000，而 Node.js 容器內部呼叫資料庫時，主機名稱要寫服務名稱 sqlserver 而非 localhost。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h1 class="wp-block-heading" id="use-the-azurite-emulator-for-local-azure-storage-development">使用 Azurite 模擬器進行本機 Azure 儲存體開發</h1>



<p>Azurite 開放原始碼模擬器提供免費的本機環境，用於測試雲端式應用程式。 當您對應用程式在本機運作方式感到滿意時，請切換至使用雲端中的 Azure 儲存體帳戶。</p>



<p>Azurite 是以 JavaScript 撰寫的輕量型儲存體模擬器，並在模擬 Azure 儲存體服務以進行本機開發的 Node.js 上執行。 它支援 Blob、佇列和資料表儲存體服務，並在 Windows、Linux 和 macOS 上提供跨平台支援。 如需安裝和執行 Azurite 的說明，請參閱&nbsp;<a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-install-azurite">安裝並執行 Azurite 模擬器</a>。</p>



<p>Azurite 還允許開發人員針對本地儲存環境運行測試，模擬 Azure 的行為，這對於整合和端到端測試至關重要。 若要深入瞭解如何使用 Azurite 進行自動化測試，請參閱&nbsp;<a href="https://learn.microsoft.com/zh-tw/azure/storage/blobs/use-azurite-to-run-automated-tests?toc=/azure/storage/blobs/toc.json&amp;bc=/azure/storage/blobs/breadcrumb/toc.json">使用 Azurite 執行自動化測試</a>。</p>



<p>Azurite 取代&nbsp;<a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-emulator">Azure 儲存體模擬器</a>，並持續更新以支援最新版本的 Azure 儲存體 API。</p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#differences-between-azurite-and-azure-storage"></a></p>



<h2 class="wp-block-heading" id="differences-between-azurite-and-azure-storage">Azurite 和 Azure 儲存體之間的差異</h2>



<p>Azurite 的本機執行個體與雲端中的 Azure 儲存體帳戶之間存在功能差異。</p>



<p>&nbsp;重要</p>



<p>Azurite 僅支援 Blob、佇列和資料表儲存體服務。 它不支援 Azure 檔案儲存體或 Azure Data Lake Storage Gen2，但在 Windows、Linux 和 macOS 上提供跨平臺支援。</p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#endpoint-and-connection-url"></a></p>



<h3 class="wp-block-heading" id="endpoint-and-connection-url">端點和連線 URL</h3>



<p>Azurite 的服務端點與 Azure 儲存體帳戶的端點不同。 本機電腦不會執行網域名稱解析，因此需要 Azurite 端點是本機位址。</p>



<p>當您在 Azure 儲存體帳戶中處理資源時，帳戶名稱是 URI 主機名稱的一部分。 要定址的資源是 URI 路徑的一部分：</p>



<p><code>&lt;http|https&gt;://&lt;account-name&gt;.&lt;service-name&gt;.core.windows.net/&lt;resource-path&gt;</code></p>



<p>下列 URI 是 Azure 儲存體帳戶中 Blob 的有效位址：</p>



<figure class="wp-block-embed"><div class="wp-block-embed__wrapper">
https://myaccount.blob.core.windows.net/mycontainer/myblob.txt
</div></figure>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#ip-style-url"></a></p>



<h4 class="wp-block-heading" id="ip-style-url">IP 樣式 URL</h4>



<p>由於本機電腦不會解析網域名稱，因此帳戶名稱是 URI 路徑的一部分，而不是主機名稱。 對 Azurite 中的資源使用下列 URI 格式：</p>



<p><code>http://&lt;local-machine-address&gt;:&lt;port&gt;/&lt;account-name&gt;/&lt;resource-path&gt;</code></p>



<p>下列位址可用來存取 Azurite 中的 Blob：</p>



<p><code>http://127.0.0.1:10000/myaccount/mycontainer/myblob.txt</code></p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#production-style-url"></a></p>



<h4 class="wp-block-heading" id="production-style-url">生產樣式 URL</h4>



<p>或者，您可以修改主機檔案，以存取具有&nbsp;<em>生產樣式</em>&nbsp;URL 的帳戶。</p>



<p>首先，將一行或多行新增至主機檔案。 例如：</p>



<pre class="wp-block-code"><code>127.0.0.1 account1.blob.localhost
127.0.0.1 account1.queue.localhost
127.0.0.1 account1.table.localhost
</code></pre>



<p>接下來，設定環境變數以啟用自訂的儲存體帳戶和金鑰：</p>



<pre class="wp-block-code"><code>set AZURITE_ACCOUNTS="account1:key1:key2"
</code></pre>



<p>您可以新增更多帳戶。 請參閱<em>連線到 Azurite</em>&nbsp;文章的<a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-connect-azurite?toc=/azure/storage/blobs/toc.json&amp;bc=/azure/storage/blobs/breadcrumb/toc.json#use-custom-storage-accounts-and-keys">自訂儲存體帳戶和金鑰</a>一節。</p>



<p>啟動 Azurite 並使用自訂連接字串存取您的帳戶。 在下列範例中，連接字串會假設使用預設連接埠。</p>



<pre class="wp-block-code"><code>DefaultEndpointsProtocol=http;AccountName=account1;AccountKey=key1;BlobEndpoint=http://account1.blob.localhost:10000;QueueEndpoint=http://account1.queue.localhost:10001;TableEndpoint=http://account1.table.localhost:10002;
</code></pre>



<p>請勿使用 Azure 儲存體總管以這種方式存取預設帳戶。 儲存體總管一律會在 URL 路徑中新增帳戶名稱，導致失敗。</p>



<p>依預設，將 Azurite 搭配生產樣式 URL 使用時，帳戶名稱應該是完整網域名稱中的主機名稱，例如&nbsp;<code>http://devstoreaccount1.blob.localhost:10000/container</code>。 若要在 URL 路徑&nbsp;<code>http://foo.bar.com:10000/devstoreaccount1/container</code>中使用具有帳戶名稱的生產樣式 URL，例如 ，請務必在啟動 Azurite 時使用參數&nbsp;<code>--disableProductStyleUrl</code>&nbsp;。</p>



<p>如果您使用&nbsp;<code>host.docker.internal</code>&nbsp;作為要求 Uri 主機 （例如：&nbsp;<code>http://host.docker.internal:10000/devstoreaccount1/container</code>），Azurite 會從要求 Uri 路徑取得帳戶名稱。 無論您在啟動 Azurite 時是否使用參數&nbsp;<code>--disableProductStyleUrl</code>&nbsp;，此行為都是正確的。</p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#scaling-and-performance"></a></p>



<h3 class="wp-block-heading" id="scaling-and-performance">調整和效能</h3>



<p>Azurite 不支援大量連線的用戶端。 沒有效能保證。 Azurite 旨在用於開發和測試目的。</p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#error-handling"></a></p>



<h3 class="wp-block-heading" id="error-handling">錯誤處理</h3>



<p>Azurite 與 Azure 儲存體錯誤處理邏輯一致，但有差異。 例如，錯誤訊息可能不同，而錯誤狀態碼則一致。</p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#ra-grs"></a></p>



<h3 class="wp-block-heading" id="ra-grs">RA-GRS</h3>



<p>Azurite 支援讀取存取異地備援複寫 （RA-GRS）。 對於儲存資源，請附加&nbsp;<code>-secondary</code>&nbsp;至帳戶名稱來存取次要位置。 例如，下列位址可用來使用 Azurite 中的唯讀次要存取 Blob：</p>



<p><code>http://127.0.0.1:10000/devstoreaccount1-secondary/mycontainer/myblob.txt</code></p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#table-support"></a></p>



<h3 class="wp-block-heading" id="table-support">表格支援</h3>



<p>對 Azurite 中表格的支援目前處於預覽階段。 如需詳細資訊，請參閱&nbsp;<a href="https://github.com/Azure/Azurite/wiki/Azurite-V3-Table">Azurite V3 表格</a>&nbsp;專案。</p>



<p>對持久函數的支援需要資料表。</p>



<p>&nbsp;重要</p>



<p>Azurite 對 Table Storage 的支援目前處於&nbsp;<strong>預覽階段</strong>。 請參閱&nbsp;<a href="https://azure.microsoft.com/support/legal/preview-supplemental-terms/">Microsoft Azure 預覽版增補使用規定</a>，以了解適用於 Azure 功能 (搶鮮版 (Beta)、預覽版，或尚未正式發行的版本) 的法律條款。</p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#azurite-is-open-source"></a></p>



<h2 class="wp-block-heading" id="azurite-is-open-source">Azurite 是開源的</h2>



<p>歡迎對 Azurite 做出貢獻和建議。 移至 Azurite&nbsp;<a href="https://github.com/Azure/Azurite/projects">GitHub 專案</a>&nbsp;頁面或&nbsp;<a href="https://github.com/Azure/Azurite/issues">GitHub 問題</a>&nbsp;，以取得我們正在追蹤即將推出的功能和錯誤修正的里程碑和工作專案。 詳細的工作專案也會在 GitHub 中追蹤。</p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#workspace-structure"></a></p>



<h2 class="wp-block-heading" id="workspace-structure">工作區結構</h2>



<p>初始化 Azurite 時，可能會在工作區位置中建立下列檔案和資料夾。</p>



<ul class="wp-block-list">
<li><code>__blobstorage__</code> &#8211; 包含 Azurite Blob 服務保存二進位資料的目錄</li>



<li><code>__queuestorage__</code> &#8211; 包含 Azurite 佇列服務保存二進位資料的目錄</li>



<li><code>__tablestorage__</code> &#8211; 包含 Azurite 資料表服務保存二進位資料的目錄</li>



<li><code>__azurite_db_blob__.json</code> &#8211; Azurite Blob 服務中繼資料檔案</li>



<li><code>__azurite_db_blob_extent__.json</code> &#8211; Azurite Blob 服務範圍中繼資料檔案</li>



<li><code>__azurite_db_queue__.json</code> &#8211; Azurite 佇列服務中繼資料檔案</li>



<li><code>__azurite_db_queue_extent__.json</code> &#8211; Azurite 佇列服務範圍 meta 資料檔</li>



<li><code>__azurite_db_table__.json</code> &#8211; Azurite 資料表服務中繼資料檔案</li>



<li><code>__azurite_db_table_extent__.json</code> &#8211; Azurite 表格服務範圍中繼資料檔案</li>
</ul>



<p>若要清理 Azurite，請刪除檔案和資料夾並重新啟動模擬器。</p>



<p><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-azurite#next-steps"></a></p>



<h2 class="wp-block-heading" id="next-steps">後續步驟</h2>



<ul class="wp-block-list">
<li><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-install-azurite">安裝並執行 Azurite 模擬器說明</a> 如何在本機電腦上安裝和執行 Azurite。</li>



<li><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-connect-azurite">使用 SDK 和工具連線到 Azurite</a> 說明如何使用各種 Azure 儲存體 SDK 和工具連線到 Azurite。</li>



<li><a href="https://learn.microsoft.com/zh-tw/azure/storage/common/storage-use-emulator">使用 Azure 儲存體模擬器進行開發和測試</a> ，記錄舊版 Azure 儲存體模擬器，並由 Azurite 取代。</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>在 React 中使用 Azure SDK 直接從瀏覽器安全地把附件上傳到 Blob</p>



<p>在 React 中直接從瀏覽器上傳檔案到 Azure Blob Storage，最常見且安全的方法是使用 <strong>SAS (Shared Access Signature)</strong> 權杖。這能避免在前端暴露你的帳戶金鑰，同時又能讓使用者直接將檔案傳給 Azure，減輕後端伺服器的負擔。</p>



<h2 class="wp-block-heading">安全上傳流程</h2>



<ol start="1" class="wp-block-list">
<li><strong>請求授權：</strong> React 前端向 Node.js 後端申請上傳權限。</li>



<li><strong>核發 SAS 權杖：</strong> 後端驗證使用者身分後，向 Azure 申請一個限時（例如 5 分鐘）且限權限（只能寫入）的臨時網址。</li>



<li><strong>直接上傳：</strong> React 拿到網址後，使用 Azure SDK 將檔案（Attachment）直接推送到 Blob Storage。</li>
</ol>



<h2 class="wp-block-heading">React 前端實作碼</h2>



<p>首先需要安裝 SDK：<code>npm install @azure/storage-blob</code></p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>import { BlockBlobClient } from '@azure/storage-blob';

async function uploadFile(file) {
  // 1. 向你的後端取得臨時的 SAS 網址 (這部分需實作 API)
  const response = await fetch('/api/get-sas-url?fileName=' + file.name);
  const { sasUrl } = await response.json();

  // 2. 建立客戶端並執行上傳
  const blobClient = new BlockBlobClient(sasUrl);
  
  try {
    await blobClient.uploadBrowserData(file, {
      onProgress: (ev) =&gt; console.log(`上傳進度: ${ev.loadedBytes}`),
      blobHTTPHeaders: { blobContentType: file.type }
    });
    alert('上傳成功！');
  } catch (error) {
    console.error('上傳失敗', error);
  }
}
</code></pre>



<h2 class="wp-block-heading">本地開發的注意事項</h2>



<p>在本地端使用 Azurite 模擬器進行測試時，有兩個關鍵點需要設定：</p>



<ul class="wp-block-list">
<li><strong>CORS 設定：</strong> 瀏覽器基於安全性會阻擋跨來源請求。你需要對 Azurite 執行命令來開啟 CORS，或是透過 Azure SDK 在後端初始化 Container 時，設定允許來自 <code>http://localhost:5173</code> (React 預設埠) 的存取。</li>



<li><strong>連接字串：</strong> 誠如前面提到的，本地測試時 SAS 產生的網址會指向 <code>http://127.0.0.1:10001</code>。請確保你的 React 環境能存取到這個位置。</li>
</ul>



<h2 class="wp-block-heading">Azure SQL 與 Blob 的關聯</h2>



<p>通常的做法是：檔案成功上傳到 Blob 後，你會拿到一個檔案路徑（URL）。接著，React 會再發一個請求給 Node.js，將這個 <strong>URL</strong> 與相關資訊（如上傳者、日期）存入 <strong>Azure SQL Database</strong>。這樣之後要讀取附件時，只需從 SQL 查出網址，前端就能直接顯示圖片或提供下載連結。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>後端 Node.js 如何產生安全的 SAS 權杖程式碼</p>



<p>產生 SAS 權杖（Shared Access Signature）是保護雲端資源的核心技術。後端 Node.js 會扮演守門員的角色，利用帳戶金鑰運算出一串具備時效性的簽名，交給前端 React 使用。</p>



<h2 class="wp-block-heading">Node.js 產生 SAS 權杖的程式碼</h2>



<p>你需要安裝 <code>@azure/storage-blob</code> 套件。以下是一個簡單的 API 邏輯範例：</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>import { 
  BlobServiceClient, 
  StorageSharedKeyCredential, 
  generateBlobSASQueryParameters, 
  BlobSASPermissions 
} from "@azure/storage-blob";

export async function getSasUrl(fileName) {
  const accountName = process.env.AZURE_STORAGE_ACCOUNT_NAME;
  const accountKey = process.env.AZURE_STORAGE_ACCOUNT_KEY;
  const containerName = "attachments";

  const sharedKeyCredential = new StorageSharedKeyCredential(accountName, accountKey);
  
  // 設定權限：允許寫入 (Create/Write)，且 5 分鐘後過期
  const sasOptions = {
    containerName,
    blobName: fileName,
    permissions: BlobSASPermissions.parse("cw"), 
    startsOn: new Date(),
    expiresOn: new Date(new Date().valueOf() + 5 * 60 * 1000), 
  };

  const sasToken = generateBlobSASQueryParameters(sasOptions, sharedKeyCredential).toString();
  
  // 回傳完整的帶權杖網址給前端
  return `https://${accountName}.blob.core.windows.net/${containerName}/${fileName}?${sasToken}`;
}
</code></pre>



<h2 class="wp-block-heading">在本地端 Azurite 測試的調整</h2>



<p>在本地使用 Azurite 模擬器時，因為連線協議是 HTTP 而非 HTTPS，且 AccountKey 是固定的測試金鑰，產生 SAS 的方式完全相同，但網址會變成 <code>http://127.0.0.1:10001/...</code>。</p>



<h2 class="wp-block-heading">功能驗證與資料庫紀錄</h2>



<p>當 React 拿到這個網址並成功上傳後，下一步通常是把檔案資訊寫入 Azure SQL。你會在 Node.js 中使用 <code>mssql</code> 套件來執行類似這樣的操作：</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>// 範例：將 Blob 網址存入 SQL Server
const request = new sql.Request();
await request.query(`
  INSERT INTO Attachments (FileName, BlobUrl, UploadTime)
  VALUES ('${fileName}', '${cleanUrl}', GETDATE())
`);
</code></pre>



<h2 class="wp-block-heading">如何在 localhost 進行完整測試</h2>



<ol start="1" class="wp-block-list">
<li><strong>啟動 Docker：</strong> 執行 <code>docker-compose up</code> 啟動 Azurite 與 SQL Server。</li>



<li><strong>建立 Container：</strong> 第一次執行時，你可以寫一個簡單的腳本確保 Azurite 裡已經建立名為 <code>attachments</code> 的容器。</li>



<li><strong>前端測試：</strong> 在 React 介面選取檔案，觀察瀏覽器的 Network 標籤。你應該會看到先發一個請求到 Node.js 拿 SAS 網址，接著 React 直接發一個 PUT 請求到 <code>127.0.0.1:10001</code>。</li>



<li><strong>檢查結果：</strong> 使用 Azure Storage Explorer 查看 Azurite 是否多了檔案，並用 SQL 管理工具查看資料表是否多了紀錄。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>檔案上傳後的預覽與權限管理，是提升使用者體驗與系統安全的重要環節。我們通常會根據檔案類型（如圖片或 PDF）來決定處理方式。</p>



<h2 class="wp-block-heading">檔案預覽的處理方式</h2>



<p>當檔案上傳到 Blob Storage 後，React 前端可以透過以下兩種方式展示預覽：</p>



<ul class="wp-block-list">
<li><strong>上傳前預覽 (Client-side)：</strong> 在檔案還沒傳到雲端前，利用瀏覽器的 <code>URL.createObjectURL(file)</code> 產生一個暫時的本地網址。這可以讓使用者立刻看到圖片，確認沒選錯檔案。</li>



<li><strong>上傳後預覽 (Server-side)：</strong> 當使用者下次回到頁面時，React 會從 Azure SQL 讀取檔案的網址。如果你的 Blob Container 設定為「私有」(Private)，你就必須再次向後端請求一個「唯讀」的 SAS 權杖，前端才能顯示這張圖片。</li>
</ul>



<h2 class="wp-block-heading">下載權限管理</h2>



<p>為了安全，我們通常不會將 Blob Container 設為公開。管理下載權限的標準做法如下：</p>



<ol start="1" class="wp-block-list">
<li><strong>動態產生下載連結：</strong> 當使用者點擊「下載」按鈕時，前端呼叫後端 API。後端確認該使用者有權限查看此附件後，產生一個有效期僅 1 分鐘的 SAS 權杖網址並回傳。</li>



<li><strong>強制瀏覽器下載：</strong> 在產生 SAS 權杖時，後端可以設定 <code>content-disposition</code> 參數。這樣當瀏覽器打開該網址時，會自動彈出「另存新檔」視窗，而不是直接在視窗中開啟檔案。</li>
</ol>



<h2 class="wp-block-heading">縮圖處理 (Thumbnail)</h2>



<p>如果你的網頁需要一次顯示大量圖片預覽（例如相簿），直接讀取原圖會讓網頁變得很慢。專業的作法是利用 <strong>Azure Functions</strong>。當檔案一傳進 Blob Storage，會觸發一個自動程式，將圖片縮小並存到另一個名為 <code>thumbnails</code> 的容器中。React 則優先讀取縮圖，等使用者點擊後才載入原圖。</p>



<h2 class="wp-block-heading">本地端測試預覽功能</h2>



<p>在 localhost 測試時，Azurite 同樣支援這些行為。你可以試著在本地端產生一個過期時間只有 10 秒的 SAS 網址，然後在網頁上重新整理，觀察圖片是否在 10 秒後就因為權限失效而無法顯示。這能幫助你驗證系統的安全性是否符合預期。</p>



<p>這套流程串聯了 React 的前端互動、Node.js 的邏輯控制、以及 Azure 的儲存服務。你想了解如何在 Azure SQL 中設計這張附件資料表，好讓它能關聯到你的使用者或專案資料嗎？</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>除了 Azure Blob Storage，針對不同的應用場景，Azure 還提供幾種存放附件或檔案的替代方案。你可以根據檔案的大小、存取頻率以及是否需要被多台伺服器共享來選擇。</p>



<h2 class="wp-block-heading">Azure Files (檔案分享)</h2>



<p>這與 Blob Storage 的主要區別在於它支援標準的 SMB 與 NFS 協定。簡單來說，你可以把它想像成一個雲端網路硬碟（網路芳鄰）。</p>



<ul class="wp-block-list">
<li><strong>使用場景：</strong> 如果你的後端程式（Node.js）是跑在多個 Docker 容器或多台虛擬機上，且這些程式需要像存取本地磁碟一樣讀寫檔案，Azure Files 非常適合。</li>



<li><strong>優點：</strong> 支援掛載為磁碟機，現有的程式碼不需要改寫成 SDK 調用，直接用檔案路徑就能讀寫。</li>
</ul>



<h2 class="wp-block-heading">Azure Data Lake Storage Gen2</h2>



<p>這是在 Blob Storage 之上構建的專門用於大數據分析的服務。</p>



<ul class="wp-block-list">
<li><strong>使用場景：</strong> 如果你的附件不僅僅是拿來下載，之後還需要進行複雜的資料分析、機器學習，或是附件數量多達數百萬、數千萬等級。</li>



<li><strong>優點：</strong> 擁有階層式命名空間（真正的資料夾結構），在處理大量檔案搬移或更名時，效能遠高於一般的 Blob。</li>
</ul>



<h2 class="wp-block-heading">資料庫直接存放 (不推薦但可行)</h2>



<p>你可以將檔案轉成 Base64 字串或二進位格式（VARBINARY），直接存入 Azure SQL Database 的欄位中。</p>



<ul class="wp-block-list">
<li><strong>使用場景：</strong> 檔案極小（例如不到 100KB 的小圖示或簽名），且你希望備份資料庫時能連同檔案一起備份，不想管理兩套系統。</li>



<li><strong>缺點：</strong> 會大幅增加資料庫的負擔與儲存成本，也會讓資料庫備份變得非常巨大且緩慢。通常我們還是建議「資料庫存路徑，Blob 存檔案」。</li>
</ul>



<h2 class="wp-block-heading">Azure App Service 內的磁碟空間</h2>



<p>如果你的 Node.js 是部署在 Azure App Service，它本身也有一小塊磁碟空間（D 槽或 /home）。</p>



<ul class="wp-block-list">
<li><strong>使用場景：</strong> 極小規模的測試專案，或檔案只是暫存性質（例如上傳後處理完就刪除）。</li>



<li><strong>風險：</strong> 當 App Service 重新啟動、更新或擴充（Scale out）時，這些檔案可能會消失。</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">該如何選擇？</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><td><strong>需求場景</strong></td><td><strong>推薦方案</strong></td></tr></thead><tbody><tr><td><strong>最通用、成本最低、React 直接上傳</strong></td><td><strong>Azure Blob Storage</strong></td></tr><tr><td><strong>多台伺服器掛載同一個網路硬碟</strong></td><td><strong>Azure Files</strong></td></tr><tr><td><strong>超大規模數據、需要資料夾權限管理</strong></td><td><strong>Data Lake Gen2</strong></td></tr><tr><td><strong>檔案極小且不想分開管理</strong></td><td><strong>Azure SQL (VARBINARY)</strong></td></tr></tbody></table></figure>



<p>在本地端驗證時，Azure Files 也有對應的 Docker 模擬器或可以用本地資料夾掛載（Volume）來模擬。你想了解如何把 Azure SQL 中的附件資料表與這些儲存路徑做關聯設計嗎？</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/docker-compose-yaml-dockerfile/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Tailwind CSS</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/tailwind-css/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/tailwind-css/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Tue, 17 Mar 2026 05:06:14 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<category><![CDATA[node.js]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7978</guid>

					<description><![CDATA[Tailwind 是一個與眾不同的 CSS 框架...]]></description>
										<content:encoded><![CDATA[
<p>Tailwind 是一個與眾不同的 CSS 框架。傳統的 CSS 框架如 Bootstrap 會提供按鈕、導覽列等現成的元件，但 Tailwind 提供的是一堆原子化的工具類別。簡單來說，它把所有 CSS 屬性拆解成一個個小標籤，讓你直接在 HTML 裡面組合出想要的樣子。你不需要離開 HTML 檔案去寫另外的 CSS 檔案，這大幅減少了切換視窗的時間。</p>



<h2 class="wp-block-heading">工具優先的開發流程</h2>



<p>在傳統開發中，如果你想做一個圓角按鈕，你需要幫 HTML 加上一個類別名稱，然後在 CSS 檔案裡定義背景顏色、圓角半徑與內距。在 Tailwind 中，你只需要在 HTML 的 class 屬性裡寫上 bg-blue-500 rounded px-4 py-2 等字眼。這種寫法雖然一開始會讓 HTML 看起來有點擁擠，但它保證了樣式的一致性，且因為不需要想類別名稱（例如該叫 primary-btn 還是 main-button），開發速度會變得很驚人。</p>



<h2 class="wp-block-heading">為什麼開發者喜歡它</h2>



<p>使用 Tailwind 最棒的一點是它內建了響應式設計。如果你想讓一張圖片在手機上隱藏但在電腦上顯示，只需要加上 hidden md:block 即可。此外，它內建了一套非常專業的調色盤與間距規範，即便你不是設計專業，透過這些預設值組合出來的介面通常也會非常有質感。由於它在打包時會自動刪除沒用到的程式碼，最終生成的 CSS 檔案體積通常比傳統寫法還要小。</p>



<h2 class="wp-block-heading">與框架的整合</h2>



<p>Tailwind 與 React 或 Vue 的搭配非常完美。因為這些框架本身就是以組件為核心，你可以把一長串的 Tailwind 標籤封裝在一個組件裡，這樣你只需要寫一次樣式，就能在專案中重複使用。目前 Node.js 環境下的開發工具如 Vite 都能完美支援 Tailwind，讓你在修改代碼的瞬間就能在瀏覽器看到樣式的變化。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>如何把計數器按鈕用 Tailwind 美化？</p>



<p>我們把剛才那個素面朝天的計數器按鈕，加上 Tailwind CSS 的工具類別。你會發現不需要寫任何一行傳統的 CSS，就能讓介面變得很專業。</p>



<h2 class="wp-block-heading">React 結合 Tailwind 的寫法</h2>



<p>在 React 中，我們使用 className 屬性來加入 Tailwind 的類別。</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>import React, { useState } from 'react';

function Counter() {
  const &#91;count, setCount] = useState(0);

  return (
    &lt;div className="flex flex-col items-center justify-center min-h-screen bg-gray-100"&gt;
      &lt;p className="text-2xl font-bold text-gray-800 mb-4"&gt;目前數字：{count}&lt;/p&gt;
      &lt;button 
        onClick={() =&gt; setCount(count + 1)}
        className="px-6 py-2 bg-blue-500 text-white rounded-lg shadow-md hover:bg-blue-600 transition duration-300"
      &gt;
        加 1
      &lt;/button&gt;
    &lt;/div&gt;
  );
}
</code></pre>



<h2 class="wp-block-heading">Vue 結合 Tailwind 的寫法</h2>



<p>Vue 的寫法同樣直觀，直接在 template 的 class 屬性中加入類別即可。</p>



<p>HTML</p>



<pre class="wp-block-code"><code>&lt;script setup&gt;
import { ref } from 'vue';
const count = ref(0);
&lt;/script&gt;

&lt;template&gt;
  &lt;div class="flex flex-col items-center justify-center min-h-screen bg-slate-50"&gt;
    &lt;p class="text-3xl font-extrabold text-indigo-600 mb-6"&gt;目前數字：{{ count }}&lt;/p&gt;
    &lt;button 
      @click="count++"
      class="px-8 py-3 bg-indigo-500 text-white font-semibold rounded-full hover:scale-105 active:scale-95 transition-transform shadow-lg"
    &gt;
      加 1
    &lt;/button&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>



<h2 class="wp-block-heading">樣式類別解構說明</h2>



<p>觀察上面的程式碼，你會看到一些常用的 Tailwind 語法：</p>



<ul class="wp-block-list">
<li>佈局控制：flex 與 items-center 讓內容居中，min-h-screen 讓背景填滿整個螢幕高度。</li>



<li>間距與大小：px-6 代表左右內距，mb-4 代表下方外距。</li>



<li>外觀與顏色：bg-blue-500 是藍色背景，rounded-lg 是大圓角，shadow-md 是陰影效果。</li>



<li>互動效果：hover:bg-blue-600 讓滑鼠移上去時變色，transition 則讓變化過程有平滑的動畫感。</li>
</ul>



<p>這種開發方式讓你不需要在 HTML 檔案與 CSS 檔案之間反覆橫跳。只要記住常用的類別名稱，開發速度會比傳統寫法快上好幾倍。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>在 VS Code 裡面安裝 Tailwind 的套件，讓它自動提示這些類別名稱</p>



<p>在 VS Code 中開發 Tailwind CSS，最核心的工具就是官方提供的擴充功能。它能讓你在撰寫時像擁有讀心術一樣，自動補完那些細碎的類別名稱。</p>



<h2 class="wp-block-heading">安裝 Tailwind CSS IntelliSense</h2>



<p>首先，請在 VS Code 的左側選單點擊擴充功能圖示，搜尋並安裝 <strong>Tailwind CSS IntelliSense</strong>。安裝完成後，只要你在 HTML 或組件的 class 屬性中開始輸入，它就會跳出建議清單。例如輸入 flex，它會顯示這代表 display: flex;，並且還會預覽顏色與間距的大小，這對記憶類別名稱非常有幫助。</p>



<h2 class="wp-block-heading">搭配 Prettier 自動排序類別</h2>



<p>當你的類別寫得越來越長，排列順序可能會變得很亂。這時候可以安裝 <strong>Prettier &#8211; Code formatter</strong> 並搭配 Tailwind 官方的插件 <strong>prettier-plugin-tailwindcss</strong>。它會在你存檔時，自動幫你把類別按照一定的邏輯重新排序。例如，它會把佈局相關的類別放在前面，顏色與裝飾性的放在後面，讓程式碼看起來非常整齊。</p>



<h2 class="wp-block-heading">檢查與調試工具</h2>



<p>如果你不確定某個類別到底對應到什麼 CSS 屬性，只需將滑鼠懸停在該類別名稱上，VS Code 就會彈出一個小視窗顯示完整的 CSS 內容。此外，如果你寫錯了語法（例如拼錯顏色名稱），編輯器會出現紅色波浪線警告你，這能避免很多低級錯誤。</p>



<h2 class="wp-block-heading">設定檔的重要性</h2>



<p>要讓這些功能正常運作，你的專案根目錄必須要有一個 <strong>tailwind.config.js</strong> 檔案。Node.js 會讀取這個設定檔來決定你的專案有哪些自定義的顏色、字體或間距。如果你發現自動提示沒有出現，通常是因為 Node.js 還沒安裝好相關套件，或是設定檔放錯了位置。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>基礎的 tailwind.config.js 設定範例，試著自定義專案的專屬顏色</p>



<p>這是一份基礎的 <code>tailwind.config.js</code> 設定檔範例。你可以透過這個檔案，定義專案專屬的品牌顏色或字體，讓 Tailwind 不再只是預設的藍色或灰色。</p>



<h2 class="wp-block-heading">基礎設定檔結構</h2>



<p>在專案根目錄建立 <code>tailwind.config.js</code>，內容如下：</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>/** @type {import('tailwindcss').Config} */
export default {
  content: &#91;
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx,vue}",
  ],
  theme: {
    extend: {
      colors: {
        'brand-primary': '#1a5cff',
        'brand-dark': '#0f172a',
        'maruko-pink': '#ff85a2',
      },
      spacing: {
        '128': '32rem',
      }
    },
  },
  plugins: &#91;],
}
</code></pre>



<h2 class="wp-block-heading">設定項目的意義</h2>



<p><code>content</code> 區塊非常重要，它告訴 Tailwind 應該去哪些檔案裡面找你寫過的類別名稱。如果沒設定好，Node.js 在打包程式碼時，會以為你沒用到這些樣式，導致網頁看起來白白的。<code>theme</code> 底下的 <code>extend</code> 則是用來擴充自定義屬性的地方。例如上面設定了 <code>maruko-pink</code>，你就可以在 HTML 裡直接寫 <code>text-maruko-pink</code> 或 <code>bg-maruko-pink</code>。</p>



<h2 class="wp-block-heading">如何套用自定義顏色</h2>



<p>當你在設定檔加入自定義顏色後，VS Code 的 IntelliSense 套件會自動讀取並在建議清單中顯示。這在開發系列產品或維護個人品牌時非常有用，你不需要記住複雜的十六進位顏色碼，只需要記住 <code>brand-primary</code> 這種有意義的名字即可。</p>



<h2 class="wp-block-heading">Node.js 的後台作業</h2>



<p>每當你修改這個設定檔並存檔，Node.js 執行的開發伺服器（如 Vite）會偵測到變動，並即時重新產生對應的 CSS 內容。這就是為什麼現代前端開發體驗這麼流暢的原因：你改動設定，瀏覽器幾乎在同一秒就反應出結果。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>在現有的 React 或 Vue 專案中，透過 npm 指令快速初始化這份設定檔</p>



<p>要在專案中啟動 Tailwind CSS，你需要用到 Node.js 的套件管理工具 npm。這幾個指令會自動幫你下載必要的檔案，並幫你把設定檔準備好。</p>



<h2 class="wp-block-heading">初始化步驟</h2>



<p>請在 VS Code 的終端機（Terminal）輸入以下指令：</p>



<p>Bash</p>



<pre class="wp-block-code"><code># 1. 安裝 Tailwind 及其相依套件
npm install -D tailwindcss postcss autoprefixer

# 2. 產生 tailwind.config.js 與 postcss.config.js 檔案
npx tailwindcss init -p
</code></pre>



<p>第一行指令會把 Tailwind 安裝在你的開發環境中。第二行指令中的 <code>-p</code> 非常重要，它會同時幫你產生 PostCSS 的設定檔。PostCSS 就像是一個翻譯官，負責把 Tailwind 的特殊語法轉譯成所有瀏覽器都能看懂的標準 CSS。</p>



<h2 class="wp-block-heading">引入 Tailwind 到專案</h2>



<p>檔案產生後，你需要在專案的 CSS 進入點（通常是 <code>src/index.css</code> 或 <code>src/main.css</code>）加入這三行指令：</p>



<p>CSS</p>



<pre class="wp-block-code"><code>@tailwind base;
@tailwind components;
@tailwind utilities;
</code></pre>



<p>這三行程式碼就像是開啟開關。Node.js 在執行時看到這三行，就會把 Tailwind 全部的工具類別庫載入進來，讓你可以在 React 或 Vue 的組件裡開始使用 <code>flex</code>、<code>bg-blue-500</code> 等類別。</p>



<h2 class="wp-block-heading">執行與驗證</h2>



<p>最後，只要啟動你的開發伺服器（通常是執行 <code>npm run dev</code>），Node.js 就會開始監控你的程式碼。如果你在設定檔中加了自定義顏色，或是修改了 HTML 裡的類別，你會發現瀏覽器會即時更新。這種開發節奏非常快，配合 VS Code 的自動補完功能，你會感覺自己像是在玩積木一樣拼湊出網頁。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>把這整套工具（Vite + React/Vue + Tailwind）一次打包成可以放上網路伺服器的靜態檔案</p>



<p>當你的網頁開發完成後，最終目標是讓全世界的人都能看到。這時候你需要透過 Node.js 把寫好的 React 或 Vue 原始碼「編譯」成瀏覽器真正讀得懂的 HTML、CSS 與 JavaScript 檔案。</p>



<h2 class="wp-block-heading">打包專案的指令</h2>



<p>在 VS Code 終端機輸入以下指令：</p>



<p>Bash</p>



<pre class="wp-block-code"><code>npm run build
</code></pre>



<p>執行這個指令後，Node.js 會啟動 Vite 或 Webpack 等打包工具。它會掃描你所有的 React/Vue 組件，找出你用到的 Tailwind 類別，然後把沒用到的樣式全部刪除（這叫作 Tree Shaking），最後在專案根目錄產生一個名為 <strong>dist</strong>（或 build）的資料夾。</p>



<h2 class="wp-block-heading">dist 資料夾裡有什麼</h2>



<p>打開 dist 資料夾，你會看到一個 <code>index.html</code> 以及存放 JavaScript 與 CSS 的資料夾。</p>



<ul class="wp-block-list">
<li><strong>精簡化 (Minification)：</strong> 程式碼會被壓縮成密密麻麻的一片，刪除所有空格與換行，縮短變數名稱，以減小檔案體積。</li>



<li><strong>快取處理：</strong> 檔名通常會帶有一串亂碼（例如 <code>main-a1b2c3.js</code>），這是為了確保當你更新網頁時，使用者的瀏覽器能抓到最新的版本，而不是舊的暫存檔。</li>
</ul>



<h2 class="wp-block-heading">如何部署到網路伺服器</h2>



<p>你只需要把這個 <strong>dist</strong> 資料夾裡的內容，上傳到任何支援靜態網頁的空間即可。目前最流行的自動化方案包括：</p>



<ol start="1" class="wp-block-list">
<li><strong>Vercel / Netlify：</strong> 只要把程式碼推送到 GitHub，它們會自動幫你執行 <code>npm run build</code> 並把網頁跑起來。</li>



<li><strong>GitHub Pages：</strong> 適合個人作品集，完全免費。</li>



<li><strong>自己的伺服器：</strong> 透過 Node.js 或 Apache/Nginx 伺服器來託管這些檔案。</li>
</ol>



<h2 class="wp-block-heading">本地預覽打包結果</h2>



<p>在正式上傳前，建議先在本地檢查打包後的網頁是否正常。你可以執行：</p>



<p>Bash</p>



<pre class="wp-block-code"><code>npm run preview
</code></pre>



<p>這會啟動一個臨時伺服器，讓你讀取 dist 資料夾的內容。這比開發模式更接近使用者看到的真實樣子，特別是可以用來檢查 Tailwind 的樣式有沒有因為設定錯誤而被意外刪除。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>把你的專案與 GitHub 連動，達成「只要存檔推送，網頁就自動更新」的自動化流程</p>



<p>要達成只要將程式碼推送到 GitHub，網頁就自動更新，我們需要建立一套自動化部署（CI/CD）流程。這聽起來很專業，但現在的工具已經把過程簡化到點幾下按鍵就能完成。</p>



<h2 class="wp-block-heading">為什麼要連動 GitHub</h2>



<p>傳統做法是每次改完程式碼都要手動執行打包，再用 FTP 上傳到伺服器，既麻煩又容易出錯。連動 GitHub 後，GitHub 就像是一個中繼站。當你執行 <code>git push</code> 把程式碼傳上去，像是 Vercel 或 Netlify 這樣的平台就會偵測到變動，主動接手幫你執行 Node.js 打包指令，並把結果發布到網路上。</p>



<h2 class="wp-block-heading">實作步驟：以 Vercel 為例</h2>



<ol start="1" class="wp-block-list">
<li><strong>建立 GitHub 儲存庫：</strong> 在 GitHub 上新增一個專案，並把你的 React 或 Vue 程式碼推送到上面。</li>



<li><strong>連結 Vercel 帳號：</strong> 到 Vercel 官網用 GitHub 帳號登入。</li>



<li><strong>匯入專案：</strong> 點擊 Import Project，選取你剛才上傳的那個儲存庫。</li>



<li><strong>設定框架：</strong> Vercel 通常會自動偵測你是用 Vite、React 還是 Vue，它會預設好打包指令（例如 <code>npm run build</code>）。你只需要點擊 Deploy。</li>
</ol>



<h2 class="wp-block-heading">享受自動化的成果</h2>



<p>部署完成後，你會得到一個網址。從這一刻起，你只要在 VS Code 修改程式碼，執行以下標準動作：</p>



<p>Bash</p>



<pre class="wp-block-code"><code>git add .
git commit -m "更新了按鈕顏色"
git push
</code></pre>



<p>一旦 push 成功，Vercel 就會開始跑進度條。大約一分鐘內，你的線上網址就會自動更新成最新的樣子。如果你不小心寫壞了程式碼導致打包失敗，Vercel 也會傳郵件通知你，並保留上一個正常的版本，不會讓網頁直接掛掉。</p>



<h2 class="wp-block-heading">協作與預覽功能</h2>



<p>如果你是跟朋友一起開發，當有人提交新的程式碼分支（Pull Request）時，Vercel 還會幫那個分支產生一個專屬的「預覽網址」。這讓你可以在正式合併到主程式之前，先點開網址看看效果是否符合預期。這在專業開發團隊中是標準配備，而現在你一個人就能輕鬆達成。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>使用 Vercel 或 Netlify 這類現代化的雲端平台，<strong>不需要</strong>手動寫 <code>.yaml</code> 檔。</p>



<h2 class="wp-block-heading">為什麼不需要 .yaml 檔</h2>



<p>在過去或是在一些較為傳統的環境（例如 GitHub Actions 或 Jenkins），你需要寫一份 YAML 格式的設定檔來告訴伺服器：第一步要安裝 Node.js，第二步要跑 <code>npm install</code>，第三步要 <code>npm run build</code>。</p>



<p>但現在的 Vercel 或 Netlify 內建了<strong>框架偵測技術</strong>。當你把 GitHub 專案連動過去時，它一看到專案裡有 <code>package.json</code> 檔案，就會自動判斷出這是一個前端專案，並主動幫你跑完所有流程。這就是為什麼我前面提到的流程可以這麼簡單。</p>



<h2 class="wp-block-heading">什麼時候才需要用到 .yaml</h2>



<p>雖然目前的操作可以跳過，但在以下兩種情況，你還是會遇到 YAML 檔案：</p>



<ul class="wp-block-list">
<li><strong>GitHub Actions：</strong> 如果你想在 GitHub 上自動跑測試，或是你想把打包後的檔案傳送到你「自己買的 Linux 伺服器」而不是 Vercel，你就必須在專案目錄下的 <code>.github/workflows/</code> 資料夾裡寫一份 YAML 檔。</li>



<li><strong>Docker 部署：</strong> 如果你的專案非常複雜，需要用到 Docker 容器化技術，通常也會用到 <code>docker-compose.yaml</code> 來管理環境。</li>
</ul>



<h2 class="wp-block-heading">懶人的最佳實踐</h2>



<p>對於個人品牌、作品集或中小型專案，我強烈建議先用 <strong>Vercel</strong> 這種免設定的方案。它把所有複雜的 YAML 設定都隱藏在後台介面中了。你只需要專注在 VS Code 裡寫好你的 React 或 Vue 程式碼，剩下的自動化工作，平台會幫你處理得妥妥當當。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/tailwind-css/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>React, Vue 框架比較</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/react-vue-compare/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/react-vue-compare/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Tue, 17 Mar 2026 04:54:41 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<category><![CDATA[node.js]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7975</guid>

					<description><![CDATA[下面分別用 React 與 Vue 寫一個簡單的...]]></description>
										<content:encoded><![CDATA[
<p>下面分別用 React 與 Vue 寫一個簡單的數字加減功能，讓你直接感受寫法上的差異。</p>



<h2 class="wp-block-heading">React 的寫法</h2>



<p>React 使用稱為 JSX 的語法，這讓你可以在 JavaScript 程式碼中直接寫 HTML 標籤。它強調資料流的單向控制，修改資料必須透過特定的函式。</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>import React, { useState } from 'react';

function Counter() {
  const &#91;count, setCount] = useState(0);

  return (
    &lt;div&gt;
      &lt;p&gt;目前數字：{count}&lt;/p&gt;
      &lt;button onClick={() =&gt; setCount(count + 1)}&gt;加 1&lt;/button&gt;
    &lt;/div&gt;
  );
}
</code></pre>



<p>在 React 中，你必須手動呼叫 setCount 來告訴程式資料改變了，頁面才會更新。這種方式雖然寫起來稍微繁瑣，但對於追蹤程式邏輯非常有幫助，讓你知道資料是在什麼時候被改動的。</p>



<h2 class="wp-block-heading">Vue 的寫法</h2>



<p>Vue 的寫法將 HTML、JavaScript 與樣式分開處理，對初學者來說非常直觀。它採用響應式系統，你只需要直接改動變數的值，頁面就會自動更新。</p>



<p>HTML</p>



<pre class="wp-block-code"><code>&lt;script setup&gt;
import { ref } from 'vue';
const count = ref(0);
&lt;/script&gt;

&lt;template&gt;
  &lt;div&gt;
    &lt;p&gt;目前數字：{{ count }}&lt;/p&gt;
    &lt;button @click="count++"&gt;加 1&lt;/button&gt;
  &lt;/div&gt;
&lt;/template&gt;
</code></pre>



<p>Vue 的 template 區塊看起來就像一般的網頁 HTML，這讓設計師或習慣傳統網頁開發的人能快速上手。你可以看到在按鈕點擊時，只需要簡單寫 count++ 就能完成更新，開發效率非常高。</p>



<h2 class="wp-block-heading">Node.js 的角色</h2>



<p>在這個範例中，Node.js 雖然沒有出現在程式碼畫面裡，但它是這兩個框架背後的推手。不論是 React 還是 Vue，你都需要在電腦安裝 Node.js 才能執行開發伺服器，並把這些程式碼轉換成瀏覽器看得懂的內容。</p>



<p>你想試著在你的電腦上安裝 Node.js 並跑跑看這些程式碼嗎？</p>



<h2 class="wp-block-heading">定位與本質的區別</h2>



<p>首先必須釐清 Node.js 與另外兩者的根本不同。Node.js 是一個執行環境，它讓 JavaScript 這種程式語言可以脫離瀏覽器，在電腦或伺服器上直接執行。如果把開發網頁比喻成蓋房子，Node.js 就像是水電系統與地基等看不見的後端架構。React 與 Vue 則屬於前端框架或函式庫，專門用來處理使用者看得到的介面，像是房子的裝潢、家具與互動開關。通常開發者會利用 Node.js 提供的工具來安裝與管理 React 或 Vue 的專案。</p>



<h2 class="wp-block-heading">React 與 Vue 的開發特色</h2>



<p>React 是由 Meta 維護的函式庫，核心思想是把網頁拆解成一個個細小的組件。它強調透過 JavaScript 來處理一切邏輯，開發自由度非常高，但學習者需要具備較強的 JavaScript 基礎，且在開發大型專案時，需要自行挑選與組合各種第三方工具。Vue 則被稱為漸進式框架，開發體驗比較貼近傳統的 HTML 寫法。Vue 的設計非常貼心，官方直接提供路由與狀態管理等工具，讓開發者不用在眾多選項中糾結。對於剛接觸前端框架的人來說，Vue 的語法通常比較直觀且容易上手。</p>



<h2 class="wp-block-heading">選擇建議與市場趨勢</h2>



<p>在選擇這三者時，Node.js 是現代網頁開發者的必備基礎，因為它是運行開發工具的引擎。至於前端的選擇，如果目標是進入大型跨國企業或追求最豐富的社群資源，React 是目前的市場主流，職缺數量也相對較多。如果你希望快速開發原型專案，或是團隊成員偏好簡單明瞭的結構，Vue 會是更有效率的選擇。在實際工作中，這三者並非競爭關係，多數情況下會是使用 Node.js 環境來同時開發 React 或 Vue 的前端介面。</p>



<p></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/react-vue-compare/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[docker][node.js] Cannot find package &#8216;multer&#8217; imported from /app/server.js</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/docker-node-js-cannot-find-package-multer-imported-from-app-server-js/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/docker-node-js-cannot-find-package-multer-imported-from-app-server-js/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Fri, 13 Mar 2026 00:43:18 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<category><![CDATA[node.js]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7941</guid>

					<description><![CDATA[直接使用 npm run dev, 網頁正常執行...]]></description>
										<content:encoded><![CDATA[
<p>直接使用 npm run dev, 網頁正常執行, 但打包為映像檔之後, 執行卻會出錯.</p>



<p>打包映像檔，名稱自訂為 my-web-app</p>



<pre class="wp-block-code"><code>docker build -t my-web-app .</code></pre>



<p>啟動容器，將本機的 8080 埠對應到容器的 8080 埠</p>



<pre class="wp-block-code"><code>docker rm -f my-running-site
docker run -d -p 8080:8080 --name my-running-site my-web-app</code></pre>



<p>在 docker 看到錯誤訊息:</p>



<pre class="wp-block-preformatted">2026-03-13 08:30:43.727 | node:internal/modules/esm/resolve:873<br>2026-03-13 08:30:43.727 |   throw new ERR_MODULE_NOT_FOUND(packageName, fileURLToPath(base), null);<br>2026-03-13 08:30:43.727 |         ^<br>2026-03-13 08:30:43.727 | <br>2026-03-13 08:30:43.727 | Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'multer' imported from /app/server.js<br>2026-03-13 08:30:43.727 |     at packageResolve (node:internal/modules/esm/resolve:873:9)<br>2026-03-13 08:30:43.727 |     at moduleResolve (node:internal/modules/esm/resolve:946:18)<br>2026-03-13 08:30:43.727 |     at defaultResolve (node:internal/modules/esm/resolve:1188:11)<br>2026-03-13 08:30:43.727 |     at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:708:12)<br>2026-03-13 08:30:43.727 |     at #cachedDefaultResolve (node:internal/modules/esm/loader:657:25)<br>2026-03-13 08:30:43.727 |     at ModuleLoader.resolve (node:internal/modules/esm/loader:640:38)<br>2026-03-13 08:30:43.727 |     at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:264:38)<br>2026-03-13 08:30:43.727 |     at ModuleJob._link (node:internal/modules/esm/module_job:168:49) {<br>2026-03-13 08:30:43.727 |   code: 'ERR_MODULE_NOT_FOUND'<br>2026-03-13 08:30:43.727 | }<br>2026-03-13 08:30:43.727 | <br>2026-03-13 08:30:43.727 | Node.js v20.20.1</pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>這個錯誤代表 Node.js 在執行 server.js 時，找不到名為 multer 的套件。雖然你可能在本地電腦開發時有安裝，但 Docker 容器是一個獨立的環境，必須確保套件也被安裝在容器內部。</p>



<h3 class="wp-block-heading">檢查 package.json 設定</h3>



<p>請確認你的 package.json 檔案中，dependencies 區塊是否包含 multer。如果沒有，請在本地資料夾執行 npm install multer，這會自動更新設定檔。</p>



<h3 class="wp-block-heading">修改 Dockerfile</h3>



<p>請檢查 Dockerfile，確保有執行安裝指令。通常正確的順序是先複製 package.json，執行安裝，再複製其餘程式碼。以下是一個標準的寫法範例：</p>



<p>Dockerfile</p>



<pre class="wp-block-code"><code>FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD &#91;"node", "server.js"]
</code></pre>



<h3 class="wp-block-heading">重新構建鏡像</h3>



<p>如果你剛新增了套件，必須重新編譯 Docker 鏡像，否則容器會一直使用舊的狀態。請執行以下指令：</p>



<p>docker build -t 你的鏡像名稱 .</p>



<h3 class="wp-block-heading">檢查掛載卷的問題</h3>



<p>如果你使用 docker-compose 並且掛載了整個開發目錄，有時候本地的 node_modules 會覆蓋容器內的環境。建議在 docker-compose.yml 中使用匿名卷來保護容器內的套件：</p>



<p>YAML</p>



<pre class="wp-block-code"><code>services:
  web:
    build: .
    volumes:
      - .:/app
      - /app/node_modules
</code></pre>



<p>這樣做可以確保容器使用自己在構建階段安裝的 multer，而不是抓取本地可能不相容或遺漏的資料夾。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>問題出在多階段構建（Multi-stage build）的邏輯。你在第二階段只安裝了生產環境套件，但你的 server.js 卻引用了 multer。</p>



<h3 class="wp-block-heading">核心原因</h3>



<p>在第二階段的指令 RUN npm install &#8211;production 中，npm 只會安裝 package.json 裡面 dependencies 區塊的套件。如果你的 multer 被歸類在 devDependencies，或者你在第一階段安裝後沒有將 package.json 同步更新，第二階段就不會安裝它。</p>



<h3 class="wp-block-heading">解決方法</h3>



<p>請檢查 package.json，確保 multer 位於 dependencies 而非 devDependencies：</p>



<p>JSON</p>



<pre class="wp-block-code"><code>{
  "dependencies": {
    "multer": "^1.4.5-lts.1",
    "express": "^4.18.2"
  }
}
</code></pre>



<p>如果 multer 已經在 dependencies 中，但仍然報錯，通常是因為 server.js 依賴的是第一階段編譯後的產物，或是 node_modules 沒被正確帶入。</p>



<h3 class="wp-block-heading">建議的 Dockerfile 修正</h3>



<p>你可以嘗試將安裝好的 node_modules 從第一階段直接複製過來，這樣可以確保兩邊環境完全一致，也不用再執行一次 npm install：</p>



<p>Dockerfile</p>



<pre class="wp-block-code"><code># 第一階段：編譯
FROM node:20-alpine AS build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# 第二階段：執行
FROM node:20-alpine
WORKDIR /app
# 直接從 build-stage 複製安裝好的 production 套件
COPY --from=build-stage /app/node_modules ./node_modules
COPY --from=build-stage /app/dist ./dist
COPY --from=build-stage /app/package.json ./package.json
COPY server.js ./

EXPOSE 8080
CMD &#91;"node", "server.js"]
</code></pre>



<h3 class="wp-block-heading">檢查 server.js 的路徑</h3>



<p>如果你的 server.js 裡面有一行 import multer from &#8216;multer&#8217;，但它其實是在執行 dist 資料夾裡的內容，請確認路徑是否正確。通常在執行 node server.js 時，它會在當前目錄的 node_modules 找套件。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>error:</p>



<pre class="wp-block-preformatted">Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'cors' imported from /app/server.js<br>    at packageResolve (node:internal/modules/esm/resolve:873:9)<br>    at moduleResolve (node:internal/modules/esm/resolve:946:18)<br>    at defaultResolve (node:internal/modules/esm/resolve:1188:11)<br>    at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:708:12)<br>    at #cachedDefaultResolve (node:internal/modules/esm/loader:657:25)<br>    at ModuleLoader.resolve (node:internal/modules/esm/loader:640:38)<br>    at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:264:38)<br>    at ModuleJob._link (node:internal/modules/esm/module_job:168:49) {<br>  code: 'ERR_MODULE_NOT_FOUND'<br>}</pre>



<p>出現這個錯誤的原因與剛才的 multer 完全相同，這代表你的 server.js 雖然引用了 cors 套件，但 Docker 容器的環境中並沒有安裝它。</p>



<h3 class="wp-block-heading">檢查 package.json 的依賴設定</h3>



<p>請打開你的 package.json 檔案，確認 dependencies 區塊中是否有 cors。如果沒有，請在本地終端機執行：</p>



<p>npm install cors</p>



<p>這會將 cors 寫入 package.json。因為你在 Dockerfile 的第二階段使用了 RUN npm install &#8211;production，如果 package.json 裡沒有列出這個套件，Docker 就不會安裝它，導致程式執行時找不到模組。</p>



<p>package.json:</p>



<pre class="wp-block-code"><code>"dependencies": {
    ...
    "cors": "^2.8.6",
    ...
}</code></pre>



<h3 class="wp-block-heading">為什麼會連續出現找不到套件的錯誤</h3>



<p>這通常是因為你在開發 server.js 時，直接使用 npm install &lt;package&gt; 安裝了多個套件（如 multer, cors），但可能沒有成功同步到 Docker 構建流程中。</p>



<h3 class="wp-block-heading">建議的解決步驟</h3>



<ol start="1" class="wp-block-list">
<li>確保所有 server.js 用到的套件（如 express, multer, cors）都出現在 package.json 的 dependencies 中。</li>



<li>重新執行 Docker 編譯指令，確保緩存被更新：docker build &#8211;no-cache -t 你的鏡像名稱 .</li>



<li>檢查 server.js 的開頭，確保引用的名稱與安裝的套件名稱一致：import cors from &#8216;cors&#8217;;</li>
</ol>



<p>如果你的 package.json 已經有這些套件卻還是報錯，建議檢查 Dockerfile 是否有正確執行到第二階段的 npm install。</p>



<p></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/docker-node-js-cannot-find-package-multer-imported-from-app-server-js/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>React Table Row 在mobile device 無法換行</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/react-table-row-mobile-device-wrap/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/react-table-row-mobile-device-wrap/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Thu, 12 Mar 2026 05:34:09 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<category><![CDATA[node.js]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7927</guid>

					<description><![CDATA[AI 詠唱出來的第一版 UI, 如果是只有使用 ...]]></description>
										<content:encoded><![CDATA[
<p>AI 詠唱出來的第一版 UI, 如果是只有使用 desktop device only, 是沒問題, table row 的顯示方式在 mobile device 上顯示不友善, 因為建言編號沒換行, 造成版面被擠壓, 換成 div 的顯示方式, 讓 mobile deivce 針對同一筆row, 可以顯示為多行.</p>



<h3 class="wp-block-heading">Key Improvements:</h3>



<ol class="wp-block-list">
<li><strong>Responsive Layout</strong>:
<ul class="wp-block-list">
<li><strong>Desktop View</strong>: Maintains a clean, professional table layout with sortable columns.</li>



<li><strong>Mobile View</strong>: Automatically switches to a <strong>card-based layout</strong>. This prevents the &#8220;squeezed&#8221; look of the table on small screens and ensures that all information (ID, subject, status, and deadline) is clearly readable.</li>
</ul>
</li>



<li><strong>Unified Styling</strong>:
<ul class="wp-block-list">
<li>The background colors for table rows and cards are now consistent across both User and Management modes to avoid visual confusion.</li>



<li>Status badges and typography have been polished for better clarity.</li>
</ul>
</li>



<li><strong>Mobile Fixes</strong>:
<ul class="wp-block-list">
<li><strong>Language Switcher</strong>: Fixed the issue where the language switcher was not clickable on mobile devices. It now uses a robust React state management with a backdrop to handle closing the menu.</li>



<li><strong>Search &amp; Filters</strong>: Adjusted the search input and status filter buttons to be more space-efficient on smaller screens.</li>
</ul>
</li>



<li><strong>Management Dashboard</strong>:
<ul class="wp-block-list">
<li>Refactored the management view to include the same responsive card system.</li>



<li>Enhanced the status filter UI with clear counts and interactive feedback.</li>
</ul>
</li>
</ol>



<p>The application has been verified to build and lint correctly, ensuring a stable experience across all devices.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>異動的程式碼:</p>



<pre class="wp-block-code"><code>         &lt;div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-          &lt;div className="overflow-x-auto"></mark>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+          {/* Desktop Table View */}
+          &lt;div className="hidden md:block overflow-x-auto">
</mark>             &lt;table className="w-full text-left border-collapse">
               &lt;thead>
                 &lt;tr className="bg-slate-50 text-slate-500 text-sm uppercase tracking-wider font-semibold">
@@ -1170,6 +1171,72 @@ export default function App() {
               &lt;/tbody>
             &lt;/table>
           &lt;/div>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+
+          {/* Mobile Card View */}
+          &lt;div className="md:hidden divide-y divide-slate-100">
+            {currentItems.length > 0 ? (
+              currentItems.map((s) => {
+                const deadlineInfo = getDeadlineInfo(s);
+                let translatedDeadlineText = deadlineInfo.text;
+                if (deadlineInfo.isDueToday) {
+                  translatedDeadlineText = t.dueToday;
+                } else if (deadlineInfo.isOverdue) {
+                  translatedDeadlineText = `${t.overdue} ${Math.abs(getWorkingDaysDifference(TODAY, new Date(deadlineInfo.deadlineDate)))} ${t.days}`;
+                } else {
+                  translatedDeadlineText = `${t.remaining} ${getWorkingDaysDifference(TODAY, new Date(deadlineInfo.deadlineDate))} ${t.days}`;
+                }
+
+                return (
+                  &lt;div
+                    key={s.id}
+                    onClick={() => handleSuggestionClick(s)}
+                    className="p-4 hover:bg-slate-50 active:bg-slate-100 transition-colors cursor-pointer space-y-3"
+                  >
+                    &lt;div className="flex justify-between items-start">
+                      &lt;div className="flex flex-col space-y-1">
+                        &lt;span className="text-&#91;10px] font-mono text-slate-400 uppercase tracking-wider">{s.id}&lt;/span>
+                        &lt;h3 className="font-bold text-slate-900 leading-tight">{s.subject}&lt;/h3>
+                        {s.isExtended &amp;&amp; (
+                          &lt;span className="text-&#91;10px] text-blue-500 font-bold uppercase tracking-tighter">{t.extendedOnce}&lt;/span>
+                        )}
+                      &lt;/div>
+                      &lt;StatusBadge status={s.status} language={language} isUserMode={true} />
+                    &lt;/div>
+
+                    &lt;div className="flex justify-between items-center text-xs">
+                      &lt;div className="flex items-center text-slate-500">
+                        &lt;Building2 size={14} className="mr-1" />
+                        &lt;span>{s.department}&lt;/span>
+                      &lt;/div>
+
+                      &lt;div className="text-right">
+                        {s.status === 'unprocessed' ? (
+                          &lt;span className="text-slate-400 italic">{t.suggestionReceived}&lt;/span>
+                        ) : (
+                          &lt;div className="flex flex-col items-end">
+                            &lt;div className={`flex items-center space-x-1 font-medium
+                              ${deadlineInfo.isDueToday ? 'text-red-600' : ''}
+                              ${deadlineInfo.isOverdue ? 'text-red-500 italic' : ''}
+                              ${!deadlineInfo.isOverdue &amp;&amp; !deadlineInfo.isDueToday ? 'text-slate-600' : ''}
+                            `}>
+                              {deadlineInfo.isDueToday || deadlineInfo.isOverdue ? &lt;AlertTriangle size={12} /> : &lt;Clock size={12} className="text-orange-400" />}
+                              &lt;span>{translatedDeadlineText}&lt;/span>
+                            &lt;/div>
+                            &lt;span className="text-&#91;10px] text-slate-400">{deadlineInfo.deadlineDate}&lt;/span>
+                          &lt;/div>
+                        )}
+                      &lt;/div>
+                    &lt;/div>
+                  &lt;/div>
+                );
+              })
+            ) : (
+              &lt;div className="p-12 text-center text-slate-400">
+                {t.noResults}
+              &lt;/div>
+            )}
+          &lt;/div>
+        &lt;/div>
</mark>           &lt;div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-slate-500">
             &lt;div className="flex items-center space-x-4">
               &lt;p>{t.all} {sortedSuggestions.length} {t.totalResults}&lt;/p>
@@ -1192,10 +1259,9 @@ export default function App() {
               &lt;/button>
             &lt;/div>
           &lt;/div>
-        &lt;/div>
-      &lt;/motion.div>
-    );
-  };
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+        &lt;/motion.div>
+      );
+    };</mark>

   const renderManagement = () => {
     const t = translations&#91;language];
@@ -1328,13 +1394,15 @@ export default function App() {
                     setSearchQuery(e.target.value);
                     setCurrentPage(1);
                   }}
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-                  className="pl-9 pr-4 py-1.5 rounded-lg border border-slate-200 text-sm focus:ring-2 focus:ring-slate-800 outline-none"
</mark><mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+                  className="pl-9 pr-4 py-1.5 rounded-lg border border-slate-200 text-sm focus:ring-2 focus:ring-slate-800 outline-none w-40 sm:w-64"
</mark>                 />
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-          &lt;div className="overflow-x-auto"></mark>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">+
+          {/* Desktop Table View */}
+          &lt;div className="hidden md:block overflow-x-auto"></mark>
             &lt;table className="w-full text-left border-collapse">
               &lt;thead>
                 &lt;tr className="bg-slate-50 text-slate-500 text-sm uppercase tracking-wider font-semibold">
@@ -1374,7 +1442,6 @@ export default function App() {
                 {currentItems.length > 0 ? (
                   currentItems.map((s) => {
                     const deadlineInfo = getDeadlineInfo(s);
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-                    // Translate deadline text
</mark>                     let translatedDeadlineText = deadlineInfo.text;
                     if (deadlineInfo.isDueToday) {
                       translatedDeadlineText = t.dueToday;
@@ -1388,30 +1455,44 @@ export default function App() {
                       &lt;tr
                         key={s.id}
                         onClick={() => handleSuggestionClick(s)}
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-                        className="hover:bg-slate-50 transition-colors cursor-pointer border-b border-slate-50 last:border-0"
</mark><mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+                        className="hover:bg-slate-50 transition-colors cursor-pointer group border-b border-slate-50 last:border-0"
</mark>                       >
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-                        &lt;td className="px-6 py-4 text-sm font-mono text-slate-500">{s.id}&lt;/td>
-                        &lt;td className="px-6 py-4 font-medium text-slate-900">{s.subject}&lt;/td>
-                        &lt;td className="px-6 py-4 text-slate-600">{s.department}&lt;/td>
-                        &lt;td className="px-6 py-4"></mark>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+                        &lt;td className="px-6 py-5 text-sm font-mono text-slate-500">
+                          &lt;span>{s.id}&lt;/span>
+                        &lt;/td>
+                        &lt;td className="px-6 py-5 font-medium text-slate-900">
+                          &lt;div className="flex flex-col">
+                            &lt;span>{s.subject}&lt;/span>
+                            {s.isExtended &amp;&amp; (
+                              &lt;span className="text-&#91;10px] text-blue-500 font-bold uppercase tracking-tighter">{t.extendedOnce}&lt;/span>
+                            )}
+                          &lt;/div>
+                        &lt;/td>
+                        &lt;td className="px-6 py-5 text-slate-600">{s.department}&lt;/td>
+                        &lt;td className="px-6 py-5"></mark>
                           &lt;StatusBadge status={s.status} language={language} isUserMode={false} />
                         &lt;/td>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-                        &lt;td className="px-6 py-4 text-right"></mark>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+                        &lt;td className="px-6 py-5 text-right">
</mark>                           &lt;div className="flex flex-col items-end space-y-0.5">
                             {s.status === 'unprocessed' ? (
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-                              &lt;div className="text-slate-400 text-xs italic"></mark>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+                              &lt;div className="text-slate-400 text-sm italic"></mark>
                                 {t.suggestionReceived}
                               &lt;/div>
                             ) : (
                               &lt;>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-                                &lt;div className={`flex items-center justify-end space-x-1 text-xs
</mark><mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+                                &lt;div className={`flex items-center justify-end space-x-1</mark>
                                   ${deadlineInfo.isDueToday ? 'text-red-600 font-medium' : ''}
                                   ${deadlineInfo.isOverdue ? 'text-red-500 font-medium italic' : ''}
                                   ${!deadlineInfo.isOverdue &amp;&amp; !deadlineInfo.isDueToday ? 'text-slate-600' : ''}
                                 `}>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+                                  {!deadlineInfo.isOverdue &amp;&amp; !deadlineInfo.isDueToday &amp;&amp; &lt;Clock size={16} className="text-orange-400" />}
+                                  {deadlineInfo.isDueToday &amp;&amp; &lt;AlertTriangle size={16} />}
+                                  {deadlineInfo.isOverdue &amp;&amp; &lt;AlertTriangle size={16} />}</mark>
                                   &lt;span>{translatedDeadlineText}&lt;/span>
                                 &lt;/div>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-                                &lt;div className="text-&#91;10px] text-slate-400">{deadlineInfo.deadlineDate}&lt;/div></mark>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+                                &lt;div className="text-&#91;10px] text-slate-400">
+                                  {t.deadlineLabel}: {deadlineInfo.deadlineDate}
+                                &lt;/div></mark>
                               &lt;/>
                             )}
                           &lt;/div>
@@ -1429,27 +1510,92 @@ export default function App() {
               &lt;/tbody>
             &lt;/table>
           &lt;/div>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">-          &lt;div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-slate-500">
-            &lt;div className="flex items-center space-x-4">
-              &lt;p>{t.all} {sortedSuggestions.length} {t.totalResults}&lt;/p>
-            &lt;/div>
-            &lt;div className="flex items-center space-x-4">
-              &lt;button
-                onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
-                disabled={currentPage === 1}
-                className="p-1 rounded hover:bg-slate-200 transition-colors disabled:opacity-30"
-              >
-                &lt;ChevronLeft size={20} />
-              &lt;/button>
-              &lt;span className="font-medium text-slate-900">{t.page} {currentPage} {t.of} {totalPages || 1} {t.pageSuffix}&lt;/span>
-              &lt;button
-                onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
-                disabled={currentPage === totalPages || totalPages === 0}
-                className="p-1 rounded hover:bg-slate-200 transition-colors disabled:opacity-30"
-              >
-                &lt;ChevronRight size={20} />
-              &lt;/button>
-            &lt;/div></mark>
<mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-green-cyan-color">+
+          {/* Mobile Card View */}
+          &lt;div className="md:hidden divide-y divide-slate-100">
+            {currentItems.length > 0 ? (
+              currentItems.map((s) => {
+                const deadlineInfo = getDeadlineInfo(s);
+                let translatedDeadlineText = deadlineInfo.text;
+                if (deadlineInfo.isDueToday) {
+                  translatedDeadlineText = t.dueToday;
+                } else if (deadlineInfo.isOverdue) {
+                  translatedDeadlineText = `${t.overdue} ${Math.abs(getWorkingDaysDifference(TODAY, new Date(deadlineInfo.deadlineDate)))} ${t.days}`;
+                } else {
+                  translatedDeadlineText = `${t.remaining} ${getWorkingDaysDifference(TODAY, new Date(deadlineInfo.deadlineDate))} ${t.days}`;
+                }
+
+                return (
+                  &lt;div
+                    key={s.id}
+                    onClick={() => handleSuggestionClick(s)}
+                    className="p-4 hover:bg-slate-50 active:bg-slate-100 transition-colors cursor-pointer space-y-3"
+                  >
+                    &lt;div className="flex justify-between items-start">
+                      &lt;div className="flex flex-col space-y-1">
+                        &lt;span className="text-&#91;10px] font-mono text-slate-400 uppercase tracking-wider">{s.id}&lt;/span>
+                        &lt;h3 className="font-bold text-slate-900 leading-tight">{s.subject}&lt;/h3>
+                        {s.isExtended &amp;&amp; (
+                          &lt;span className="text-&#91;10px] text-blue-500 font-bold uppercase tracking-tighter">{t.extendedOnce}&lt;/span>
+                        )}
+                      &lt;/div>
+                      &lt;StatusBadge status={s.status} language={language} isUserMode={false} />
+                    &lt;/div>
+
+                    &lt;div className="flex justify-between items-center text-xs">
+                      &lt;div className="flex items-center text-slate-500">
+                        &lt;Building2 size={14} className="mr-1" />
+                        &lt;span>{s.department}&lt;/span>
+                      &lt;/div>
+                      &lt;div className="text-right">
+                        {s.status === 'unprocessed' ? (
+                          &lt;span className="text-slate-400 italic">{t.suggestionReceived}&lt;/span>
+                        ) : (
+                          &lt;div className="flex flex-col items-end">
+                            &lt;div className={`flex items-center space-x-1 font-medium
+                              ${deadlineInfo.isDueToday ? 'text-red-600' : ''}
+                              ${deadlineInfo.isOverdue ? 'text-red-500 italic' : ''}
+                              ${!deadlineInfo.isOverdue &amp;&amp; !deadlineInfo.isDueToday ? 'text-slate-600' : ''}
+                            `}>
+                              {deadlineInfo.isDueToday || deadlineInfo.isOverdue ? &lt;AlertTriangle size={12} /> : &lt;Clock size={12} className="text-orange-400" />}
+                              &lt;span>{translatedDeadlineText}&lt;/span>
+                            &lt;/div>
+                            &lt;span className="text-&#91;10px] text-slate-400">{deadlineInfo.deadlineDate}&lt;/span>
+                          &lt;/div>
+                        )}
+                      &lt;/div>
+                    &lt;/div>
+                  &lt;/div>
+                );
+              })
+            ) : (
+              &lt;div className="p-12 text-center text-slate-400">
+                {t.noResults}
+              &lt;/div>
+            )}
+          &lt;/div>
+        &lt;/div>
+              &lt;/div>
+            )}
+          &lt;/div>
+        &lt;/div>
+        &lt;div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex flex-col sm:flex-row justify-between items-center gap-4 text-sm text-slate-500">
+          &lt;div className="flex items-center space-x-4">
+            &lt;p>{t.all} {sortedSuggestions.length} {t.totalResults}&lt;/p>
+          &lt;/div>
+          &lt;div className="flex items-center space-x-4">
+            &lt;button
+              onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
+              disabled={currentPage === 1}
+              className="p-1 rounded hover:bg-slate-200 transition-colors disabled:opacity-30"
+            >
+              &lt;ChevronLeft size={20} />
+            &lt;/button>
+            &lt;span className="font-medium text-slate-900">{t.page} {currentPage} {t.of} {totalPages || 1} {t.pageSuffix}&lt;/span>
+            &lt;button
+              onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
+              disabled={currentPage === totalPages || totalPages === 0}
+              className="p-1 rounded hover:bg-slate-200 transition-colors disabled:opacity-30"
+            >
+              &lt;ChevronRight size={20} />
+            &lt;/button></mark>
           &lt;/div>
         &lt;/div></code></pre>



<p></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/react-table-row-mobile-device-wrap/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>node.js 的 API 常見 testcase</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/node-js-backend-api-testcase/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/node-js-backend-api-testcase/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Wed, 11 Mar 2026 09:27:43 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7920</guid>

					<description><![CDATA[若要為現有的 Node.js API 撰寫持續整...]]></description>
										<content:encoded><![CDATA[
<p>若要為現有的 Node.js API 撰寫持續整合 (CI) 測試，核心目標是確保每次程式碼變更後，既有的功能不會損壞。這通常從整合測試 (Integration Testing) 入手最有效率。</p>



<h3 class="wp-block-heading">撰寫 Prompt 的策略</h3>



<p>在下 Prompt 給 AI 時，建議直接提供 server.js 的程式碼片段或 API 路由定義。你可以使用類似以下的範例：</p>



<p>我有一個 Node.js 的 server.js 檔案，使用了 Express 框架。請根據這段程式碼，使用 Jest 和 Supertest 撰寫 API 自動化測試腳本。我需要包含正常的請求測試，以及錯誤處理的邊界測試。請直接提供測試程式碼，並告訴我如何在 package.json 設定測試指令。</p>



<p>如果你的 API 有連接資料庫，記得在 Prompt 中補充：請使用 Mock 的方式處理資料庫連線，避免測試過程污染真實數據。</p>



<h3 class="wp-block-heading">必備的 Test Case 類型</h3>



<p>針對 API 伺服器，通常需要涵蓋以下幾類測試案例：</p>



<ol start="1" class="wp-block-list">
<li>狀態碼檢查：確保成功的請求回傳 200 或 201，不存在的頁面回傳 404，未授權的存取回傳 401。</li>



<li>資料結構驗證：檢查 API 回傳的 JSON 格式是否符合預期，欄位名稱與資料型別是否正確。</li>



<li>邊界條件測試：輸入空的參數、過長的字串或不合法的格式，測試後端是否能正確回傳錯誤訊息而非程式崩潰。</li>



<li>資料庫互動測試：例如新增一筆資料後，呼叫查詢 API 是否真的能抓到該筆資料。</li>
</ol>



<h3 class="wp-block-heading">推薦的測試工具組合</h3>



<p>目前 Node.js 社群最主流的組合是 Jest 搭配 Supertest。Jest 是測試執行器，負責執行測試並產出報告；Supertest 則是用來模擬 HTTP 請求，不需要真的啟動伺服器就能測試 API 路徑。</p>



<p>在 CI 流程中（如 GitHub Actions 或 GitLab CI），你會設定一個腳本自動執行 npm test。如果所有測試案例都通過，代表目前的變更是安全的，才能允許合併程式碼。</p>



<h3 class="wp-block-heading">下一步建議</h3>



<p>你可以試著把 server.js 其中一個最重要的 API 路由貼給我，我可以幫你示範如何撰寫對應的測試腳本與 CI 流程設定。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>我已經根據您的需求，使用&nbsp;<strong>Jest</strong>&nbsp;與&nbsp;<strong>Supertest</strong>&nbsp;撰寫了完整的 API 自動化測試腳本。為了能在測試環境中獨立運行而不污染真實數據，我對&nbsp;</p>



<p>server.js&nbsp;進行了細微調整，並使用了&nbsp;<strong>Mock</strong>&nbsp;技術來模擬資料庫連線。</p>



<h3 class="wp-block-heading" id="user-content-1-準備工作已完成的環境設定">1. 準備工作：已完成的環境設定</h3>



<p>我已經為您安裝了必要的開發依賴：</p>



<ul class="wp-block-list">
<li><code>jest</code>: 測試框架</li>



<li><code>supertest</code>: 用於模擬 HTTP 請求的工具</li>



<li><code>jest-environment-node</code>: Node.js 測試環境</li>
</ul>



<p>並在&nbsp;</p>



<p>package.json&nbsp;的&nbsp;<code>scripts</code>&nbsp;中新增了測試指令：</p>



<pre class="wp-block-preformatted">json"scripts": {  "test": "NODE_OPTIONS=--experimental-vm-modules NODE_ENV=test jest"}</pre>



<p>NOTE</p>



<p>由於專案使用 ESM (ES Modules)，因此必須加上&nbsp;<code>--experimental-vm-modules</code>&nbsp;參數。</p>



<h3 class="wp-block-heading" id="user-content-2-api-測試腳本-servertestjs">2. API 測試腳本 (server.test.js)</h3>



<p>此測試涵蓋了建言查詢、新增、更新、以及設定值的存取，包含了成功路徑與錯誤處理（如 500 錯誤與 404 回退）。</p>



<pre class="wp-block-preformatted">import request from 'supertest';<br>import { jest } from '@jest/globals';<br><br>// 關鍵：使用 Mock 模擬 better-sqlite3，避免連動真實 database.sqlite<br>jest.unstable_mockModule('better-sqlite3', () => {<br>    const mStmt = {<br>        run: jest.fn().mockReturnValue({ changes: 1 }),<br>        all: jest.fn().mockReturnValue([]),<br>        get: jest.fn().mockReturnValue({}),<br>    };<br>    const mDb = {<br>        prepare: jest.fn().mockReturnValue(mStmt),<br>        transaction: jest.fn(cb => (...args) => cb(...args)),<br>        exec: jest.fn(),<br>    };<br>    return {<br>        default: jest.fn(() => mDb)<br>    };<br>});<br><br>// 在 Mock 建立後再匯入 app 與 db<br>const { app, db } = await import('./server.js');<br><br>describe('API 整合測試', () => {<br>    <br>    beforeEach(() => {<br>        jest.clearAllMocks();<br>    });<br><br>    describe('GET /api/suggestions (狀態碼與資料結構驗證)', () => {<br>        test('應成功傳回建言列表與 200 狀態碼', async () => {<br>            db.prepare().all<br>                .mockReturnValueOnce([{ id: '2026-001', subject: '測試', status: 'unprocessed', isExtended: 0 }])<br>                .mockReturnValueOnce([]); // 附件<br><br>            const res = await request(app).get('/api/suggestions');<br>            <br>            expect(res.statusCode).toBe(200);<br>            expect(Array.isArray(res.body)).toBe(true);<br>            expect(res.body[0]).toHaveProperty('id', '2026-001');<br>        });<br><br>        test('當資料庫查詢失敗時應傳回 500', async () => {<br>            db.prepare().all.mockImplementation(() => { throw new Error('DB Error'); });<br>            const res = await request(app).get('/api/suggestions');<br>            expect(res.statusCode).toBe(500);<br>            expect(res.body.error).toBe('DB Error');<br>        });<br>    });<br><br>    describe('POST /api/suggestions (新增與邊界測試)', () => {<br>        test('成功新增建言應傳回 201', async () => {<br>            const res = await request(app)<br>                .post('/api/suggestions')<br>                .send({<br>                    id: 'NEW-01',<br>                    subject: '新建議',<br>                    content: '內容描述'<br>                });<br>            expect(res.statusCode).toBe(201);<br>            expect(res.body.success).toBe(true);<br>        });<br>    });<br><br>    describe('PUT /api/settings (資料庫互動與錯誤處理)', () => {<br>        test('應能成功更新設定值', async () => {<br>            const res = await request(app)<br>                .put('/api/settings')<br>                .send({ buildingSpaceDesc: '更新內容' });<br>            expect(res.statusCode).toBe(200);<br>            expect(res.body.success).toBe(true);<br>            // 驗證是否真的呼叫了 SQL<br>            expect(db.prepare).toHaveBeenCalledWith(expect.stringContaining('UPDATE settings'));<br>        });<br>    });<br><br>    describe('SPA Fallback (404 檢查)', () => {<br>        test('存取不存在的路徑應回傳 index.html 內容', async () => {<br>            const res = await request(app).get('/some-non-existent-page');<br>            expect(res.type).toBe('text/html');<br>        });<br>    });<br>});<br></pre>



<h3 class="wp-block-heading" id="user-content-3-如何執行測試">3. 如何執行測試</h3>



<p>請在終端機執行以下指令：</p>



<pre class="wp-block-preformatted">npm test</pre>



<h3 class="wp-block-heading" id="user-content-4-程式碼修改說明">4. 程式碼修改說明</h3>



<p>為了支援測試，我對&nbsp;</p>



<p>server.js&nbsp;做了以下微調：</p>



<ul class="wp-block-list">
<li><strong>導出物件</strong>：在檔案末尾加上 <code>export { app, db }</code>，讓測試腳本可以存取實體。</li>



<li><strong>防止自動啟動</strong>：加上 <code>if (process.env.NODE_ENV !== 'test')</code> 判斷，避免在執行測試時重複啟動 8080 端口。</li>
</ul>



<p>這個測試方案可以確保您的後端邏輯在修改過程中不會發生</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/node-js-backend-api-testcase/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Node.js 多語系檔案</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/node-js-i18next/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/node-js-i18next/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 09 Mar 2026 07:22:57 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<category><![CDATA[node.js]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7864</guid>

					<description><![CDATA[在 Node.js 專管語系檔案時，通常會使用物...]]></description>
										<content:encoded><![CDATA[
<p>在 Node.js 專管語系檔案時，通常會使用物件格式來儲存字串。要在字串中嵌入變數，最常見的做法是使用大括號作為佔位符，並配合取代函數或是 i18next 這類套件來處理。</p>



<h3 class="wp-block-heading">基礎實作方式</h3>



<p>如果不使用外部套件，可以定義一個簡單的取代函式。在 translations.ts 中，字串會長這樣：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>export const translations = {
  welcome: "你好，{name}！歡迎回來。",
  items: "你有 {count} 個待辦事項。"
};
</code></pre>



<p>接著撰寫一個處理邏輯，將大括號內的標記替換為實際數值：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>function t(key: keyof typeof translations, variables: Record&lt;string, string | number&gt;) {
  let message = translations&#91;key];
  
  Object.keys(variables).forEach(placeholder =&gt; {
    const value = variables&#91;placeholder];
    message = message.replace(`{${placeholder}}`, String(value));
  });
  
  return message;
}

const greeting = t("welcome", { name: "小明" });
console.log(greeting); // 輸出：你好，小明！歡迎回來。
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">使用 i18next 套件</h3>



<p>如果你使用的是業界標準的 i18next，語法會稍微不同，它預設支援雙大括號或是特定格式。在語系檔中：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>{
  "welcome": "你好，{{name}}！"
}
</code></pre>



<p>在程式碼中調用的方式：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>import i18next from 'i18next';

i18next.t('welcome', { name: '小明' });
</code></pre>



<p>這種方式的好處是套件會自動幫你處理複雜的格式，例如數字千分位或日期格式化。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">注意事項</h3>



<p>確保變數名稱在字串與程式碼中完全一致。如果翻譯檔是從外部 JSON 匯入的，建議建立一個介面來約束變數名稱，避免因為打錯字導致變數沒有成功被取代，畫面直接顯示出 {name} 本身。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>支援多層級物件的翻譯解析函式</p>



<p>這是一個支援多層級物件（例如：common.welcome）且能自動取代變數的進階實作。這種結構在大型 Node.js 專案中非常實用。</p>



<h3 class="wp-block-heading">translations.ts 語系檔結構</h3>



<p>建議將語系內容定義為巢狀物件，並使用 TypeScript 的物件結構來確保型別安全：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>export const translations = {
  common: {
    welcome: "你好，{name}！歡迎來到我們的平台。",
    error: "錯誤碼：{code}"
  },
  shop: {
    cart: "你的購物車中有 {count} 件商品。"
  }
};
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">解析函式實作</h3>



<p>這個函式會根據點分隔符號（例如 &#8220;common.welcome&#8221;）找到正確的字串，再執行變數替換：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>type TranslationData = typeof translations;

function getDeepValue(obj: any, path: string) {
  return path.split('.').reduce((prev, curr) =&gt; prev &amp;&amp; prev&#91;curr], obj);
}

function translate(key: string, params: Record&lt;string, string | number&gt; = {}) {
  let template = getDeepValue(translations, key);

  if (!template || typeof template !== 'string') {
    return key; // 如果找不到對應字串，回傳路徑名稱作為提示
  }

  // 遍歷所有傳入的變數並進行全域替換
  Object.keys(params).forEach(param =&gt; {
    const value = params&#91;param];
    const regex = new RegExp(`{${param}}`, 'g');
    template = template.replace(regex, String(value));
  });

  return template;
}
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">如何使用</h3>



<p>你可以在專案中的任何地方呼叫這個函式。這段邏輯模擬了 i18n 工具的基本行為：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>const userName = "王小明";
const loginMessage = translate("common.welcome", { name: userName });
console.log(loginMessage); // 輸出：你好，王小明！歡迎來到我們的平台。

const cartMessage = translate("shop.cart", { count: 5 });
console.log(cartMessage); // 輸出：你的購物車中有 5 件商品。
</code></pre>



<h3 class="wp-block-heading">進階建議</h3>



<p>如果你的翻譯需求變得更複雜，例如需要處理複數形式（1 item vs 2 items）或性別差異，建議轉向使用 i18next 或 formatjs 這些成熟的函式庫。但對於大多數中小型專案，上述的字串取代邏輯已經足夠應付 90% 的情境。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p>要在 React 元件中處理這類動態變數，最直覺且不需要改動太多現有程式碼的方法是將 <code>t.hi</code> 視為範本字串，並在渲染時使用 <code>replace</code> 方法。</p>



<h3 class="wp-block-heading">直接在 JSX 中處理</h3>



<p>你可以直接在 <code>&lt;h2&gt;</code> 標籤內對字串進行取代。這種做法最快，適合專案規模較小的情況：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>&lt;h2 className="text-2xl font-semibold text-slate-900 mb-1"&gt;
  {t.hi.replace('{username}', username)}
&lt;/h2&gt;
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">建立一個簡單的翻譯工具函式</h3>



<p>如果你的專案中有很多類似的變數需求，建議寫一個輔助函式處理取代邏輯，避免在 UI 程式碼中到處寫 <code>replace</code>：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>// 輔助函式定義
const interpolate = (str: string, params: Record&lt;string, string&gt;) =&gt; {
  let result = str;
  for (const &#91;key, value] of Object.entries(params)) {
    result = result.replace(`{${key}}`, value);
  }
  return result;
};

// 在元件中使用
&lt;h2 className="text-2xl font-semibold text-slate-900 mb-1"&gt;
  {interpolate(t.hi, { username })}
&lt;/h2&gt;
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">將翻譯內容改為函式（推薦做法）</h3>



<p>為了確保型別安全並減少字串處理的效能損耗，另一種常見的做法是讓語系檔中的特定項目支援函式：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>// 修改語系檔定義
export const translations = {
  zh: {
    // ... 其他內容
    hi: (username: string) =&gt; `Hi ${username}！`,
  }
};

// 在元件中使用
&lt;h2 className="text-2xl font-semibold text-slate-900 mb-1"&gt;
  {typeof t.hi === 'function' ? t.hi(username) : t.hi}
&lt;/h2&gt;
</code></pre>



<h3 class="wp-block-heading">總結建議</h3>



<ol start="1" class="wp-block-list">
<li>如果只有一兩個地方要用，直接用 <strong>replace</strong> 即可。</li>



<li>如果你的校務建言系統規模會持續擴大，建議建立一個 <strong>interpolate</strong> 工具函式，這樣以後遇到 <code>{date}</code> 或 <code>{count}</code> 等變數也能統一處理。</li>



<li>檢查你的 <code>t.welcome</code> 是否也包含變數，處理邏輯是一樣的。</li>
</ol>



<p></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/node-js-i18next/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>開發模式執行 npm run dev 時，Vite 預設只會啟動「前端開發伺服器」</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/npm-run-dev-vite-server/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/npm-run-dev-vite-server/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 09 Mar 2026 02:49:49 +0000</pubDate>
				<category><![CDATA[node.js筆記]]></category>
		<category><![CDATA[node.js]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7855</guid>

					<description><![CDATA[當你在開發模式執行 npm run dev 時，...]]></description>
										<content:encoded><![CDATA[
<p>當你在開發模式執行 <code>npm run dev</code> 時，Vite 預設只會啟動「前端開發伺服器」，它並不認識你在 <code>server.js</code> 寫的 API 路由。</p>



<p>要讓前端順利呼叫到 <code>/api/config-test</code>，你有兩個主要的處理方式：</p>



<h3 class="wp-block-heading">1. 同時啟動兩個服務 (最保險)</h3>



<p>在開發階段，你需要同時開啟兩個終端機視窗：</p>



<ul class="wp-block-list">
<li><strong>視窗 A</strong>：執行 <code>node server.js</code>（啟動後端 API，預設在 8080 埠）。</li>



<li><strong>視窗 B</strong>：執行 <code>npm run dev</code>（啟動前端介面，預設在 5173 埠）。</li>
</ul>



<p>此時，如果你直接在瀏覽器輸入 <code>http://localhost:8080/api/config-test</code>，你應該就能看到 JSON 資料。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">2. 設定 Vite 代理 (Proxy)</h3>



<p>為了讓前端程式碼（5173）呼叫 <code>/api/...</code> 時能自動轉發給後端（8080），你需要修改 <code>vite.config.ts</code> (或 <code>.js</code>)：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: &#91;react()],
  server: {
    proxy: {
      // 當前端呼叫 /api 時，轉發到 8080
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
})
</code></pre>



<p>設定好 Proxy 後，你在前端程式碼中就可以這樣呼叫：</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>fetch('/api/config-test')
  .then(res =&gt; res.json())
  .then(data =&gt; console.log(data));
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">3. 在前端畫面中顯示資料 (驗證方式)</h3>



<p>如果你想直接在 AI Studio 產生的畫面上看到結果，可以在你的 <code>.tsx</code> 元件中加入這段測試代碼：</p>



<p>TypeScript</p>



<pre class="wp-block-code"><code>import { useEffect, useState } from 'react';

function TestComponent() {
  const &#91;dbInfo, setDbInfo] = useState('載入中...');

  useEffect(() =&gt; {
    fetch('/api/config-test')
      .then(res =&gt; res.json())
      .then(data =&gt; setDbInfo(data.dbName))
      .catch(err =&gt; setDbInfo('呼叫失敗'));
  }, &#91;]);

  return &lt;div&gt;資料庫名稱：{dbInfo}&lt;/div&gt;;
}
</code></pre>



<h3 class="wp-block-heading">總結驗證流程</h3>



<ol start="1" class="wp-block-list">
<li>確保 <strong>Docker 容器已關閉</strong>（避免佔用 8080 埠）。</li>



<li>啟動後端：<code>node server.js</code>。</li>



<li>啟動前端：<code>npm run dev</code>。</li>



<li>打開瀏覽器 <code>http://localhost:5173/api/config-test</code>。如果看到 JSON，代表 Proxy 設定成功。</li>
</ol>



<p></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/npm-run-dev-vite-server/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>連線 Azure SQL 的 Node.js 範例代碼</title>
		<link>https://stackoverflow.max-everyday.com/2026/03/azure-sql-node-js-example/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/03/azure-sql-node-js-example/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 09 Mar 2026 02:43:28 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[node.js筆記]]></category>
		<category><![CDATA[azure]]></category>
		<category><![CDATA[node.js]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=7849</guid>

					<description><![CDATA[既然你的開發環境已經串接成功，接下來就是把 se...]]></description>
										<content:encoded><![CDATA[
<p>既然你的開發環境已經串接成功，接下來就是把 <code>server.js</code> 升級，讓它具備連線到 <strong>Azure SQL Database</strong> 的能力。</p>



<p>在 Azure App Service 上，我們強烈建議使用 <strong>受控識別 (Managed Identity)</strong>。這能讓你的程式碼在沒有帳號密碼的情況下，安全地存取資料庫。</p>



<h3 class="wp-block-heading">1. 安裝必要套件</h3>



<p>在你的專案目錄下執行以下指令，安裝 SQL 驅動程式與 Azure 身分驗證工具：</p>



<p>PowerShell</p>



<pre class="wp-block-code"><code>npm install mssql @azure/identity
</code></pre>



<h3 class="wp-block-heading">2. 更新 <code>server.js</code> 加入 SQL 連線邏輯</h3>



<p>請將 <code>server.js</code> 修改為以下內容。這個版本整合了原本的靜態檔案託管與新的資料庫查詢功能：</p>



<p>JavaScript</p>



<pre class="wp-block-code"><code>import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import sql from 'mssql';
import { DefaultAzureCredential } from '@azure/identity';

const app = express();
const port = process.env.PORT || 8080;

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

app.use(express.static(path.join(__dirname, 'dist')));

// Azure SQL 連線設定
const config = {
    server: process.env.DB_SERVER, // 例如: your-server.database.windows.net
    database: process.env.DB_NAME,
    options: {
        encrypt: true,
        trustServerCertificate: false
    },
    authentication: {
        type: 'azure-active-directory-default', // 使用受控識別
        options: {
            credential: new DefaultAzureCredential()
        }
    }
};

// 建立一個 API 路由來抓取資料庫資料
app.get('/api/sql-data', async (req, res) =&gt; {
    try {
        console.log('正在嘗試連線到 SQL Server...');
        let pool = await sql.connect(config);
        let result = await pool.request().query('SELECT TOP 5 * FROM YourTableName');
        res.json(result.recordset);
    } catch (err) {
        console.error('SQL 錯誤:', err);
        res.status(500).json({ error: '無法讀取資料庫', details: err.message });
    }
});

app.get('/api/config-test', (req, res) =&gt; {
    res.json({
        dbServer: process.env.DB_SERVER || "未設定",
        dbName: process.env.DB_NAME || "未設定"
    });
});

app.get('*', (req, res) =&gt; {
    res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});

app.listen(port, () =&gt; {
    console.log(`Server running at http://localhost:${port}`);
});
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">3. 如何在 Local 測試連線？</h3>



<p>由於 <code>DefaultAzureCredential</code> 非常聰明，它會依照環境切換驗證方式：</p>



<ol start="1" class="wp-block-list">
<li><strong>在 Local</strong>：它會嘗試抓取你電腦上 <code>az login</code> 的身分。請確保你在 PowerShell 執行過 <code>az login</code> 並有權限存取該資料庫。</li>



<li><strong>在 Azure</strong>：它會自動改用 App Service 的「受控識別」。</li>
</ol>



<p>測試時，請先在 PowerShell 設定環境變數：</p>



<p>PowerShell</p>



<pre class="wp-block-code"><code>$env:DB_SERVER="你的伺服器.database.windows.net"
$env:DB_NAME="你的資料庫名稱"
node server.js
</code></pre>



<h3 class="wp-block-heading">4. 重要：資料庫端的權限設定</h3>



<p>即便程式碼寫好了，你還是必須在 Azure SQL Database 裡手動幫你的 App Service (或你的個人帳號) 建立權限：</p>



<p>SQL</p>



<pre class="wp-block-code"><code>-- 在 SQL 查詢編輯器執行
CREATE USER &#91;你的-App-Service-名稱] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER &#91;你的-App-Service-名稱];
</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">5. 最後打包 Docker 的提醒</h3>



<p>因為我們新增了 <code>mssql</code> 與 <code>@azure/identity</code>，請確保你的 <code>Dockerfile</code> 執行階段有包含 <code>RUN npm install --production</code>，或者直接複製整個 <code>node_modules</code>。</p>



<p>你的基礎建設已經全部準備就緒：<strong>前端 Vite + 後端 Node.js + Docker 打包 + SQL 連線邏輯</strong>。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/03/azure-sql-node-js-example/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
