# 客戶管理平台 — UI Interaction Spec

> **版本**：v1.0 &nbsp;|&nbsp; **日期**：2026-05-05 &nbsp;|&nbsp; **用途**：工程師串接參考
>
> 視覺版（含 Figma 風格標注）請開啟 [`docs/ui_interaction_spec.html`](./ui_interaction_spec.html)

---

## 目錄

- [系統概覽](#系統概覽)
- [API Endpoints 速查表](#api-endpoints-速查表)
- [Frame A — 客戶名單頁](#frame-a--客戶名單頁)
- [Frame B — 貿小七搜尋頁](#frame-b--貿小七搜尋頁)
- [Frame C — 任務進度](#frame-c--任務進度)
- [背景執行流程（Windows）](#背景執行流程windows)
- [待串接項目](#待串接項目)

---

## 系統概覽

```
前端 UI (contacts_ui.html)
        ↕ HTTP REST
   FastAPI  port 8000  (api/main.py)
   ├── /contacts   → api/routes/contacts.py
   ├── /jobs       → api/routes/jobs.py
   └── /products   → api/routes/products.py
        ↕ pymysql
   MySQL  192.168.0.127
   ├── altra_marketing.contacts  (聯絡人主檔)
   ├── altra_marketing.jobs      (任務追蹤)
   └── altra_agent.domain_cache  (公司→域名快取)
```

**背景鏈（使用者不可見）**
```
POST /jobs/trigger → n8n Webhook
  → maoxiaoqi_batch_search.py  (Playwright RPA)
    → domain_lookup.py         (Tavily API + MySQL cache)
    → 貿小七 domain search
    → POST /contacts/import
    → PATCH /jobs/:id  (更新進度)
```

---

## API Endpoints 速查表

| 方法 | Endpoint | 說明 | Source |
|---|---|---|---|
| `GET` | `/contacts` | 查詢聯絡人（含篩選） | `contacts.py:44` |
| `POST` | `/contacts` | 手動新增單筆 | `contacts.py:410` |
| `GET` | `/contacts/stats` | 統計數字 | `contacts.py:138` |
| `GET` | `/contacts/export` | 匯出 CSV（帶篩選條件） | `contacts.py:339` |
| `POST` | `/contacts/import` | 匯入 CSV / XLSX | `contacts.py:195` |
| `POST` | `/contacts/scan-bounce` | 觸發 IMAP Bounce 掃描 | `contacts.py:85` |
| `GET` | `/contacts/:id` | 單筆查詢 | `contacts.py:396` |
| `PATCH` | `/contacts/:id` | 更新聯絡人欄位 | `contacts.py:456` |
| `DELETE` | `/contacts/:id` | 軟刪除（status → 封鎖） | `contacts.py:484` |
| `GET` | `/jobs` | 查詢任務列表 | `jobs.py:150` |
| `POST` | `/jobs` | 建立任務記錄 | `jobs.py:45` |
| `PATCH` | `/jobs/:id` | 更新任務進度 | `jobs.py:81` |
| `GET` | `/jobs/:id` | 查詢單一任務 | `jobs.py:162` |
| `POST` | `/jobs/trigger` | 觸發 n8n 搜尋 | `jobs.py:121` |
| `GET` | `/products` | 取得所有產品 | `products.py:59` |
| `GET` | `/products/:id` | 取得單一產品 | `products.py:64` |

---

## Frame A — 客戶名單頁

### A1 · 統計數字（Stats Cards）

**觸發時機**：頁面載入 / 匯入完成 / Bounce 掃描完成後自動執行

```
GET /contacts/stats
```

**回傳**
```json
{
  "total": 689,
  "companies": 42,
  "有效": 612,
  "Bounce": 21,
  "待確認": 56
}
```

**前端**：`contacts_ui.html:1051` `updateStats()`  
**後端**：`api/routes/contacts.py:138` `get_stats()`  
**DB 查詢**：`SELECT COUNT(*) FROM contacts GROUP BY status`

> 點擊卡片 → 自動更新 `f-status` 篩選器並觸發 A3

---

### A2 · 產品篩選下拉（f-product）

**觸發時機**：頁面初始化時，與 B1 共用同一次 `loadProducts()` 呼叫

```
GET /products
```

**回傳**
```json
[
  { "id": "low-dk-pi", "name": "Low-Dk PI", "status": "新產品", "segments": [...] },
  { "id": "hdi",       "name": "HDI",       "status": "既有產品", "segments": [...] },
  { "id": "varnish",   "name": "凡立水",    "status": "既有產品", "segments": [...] },
  { "id": "fiw",       "name": "FIW",       "status": "既有產品", "segments": [...] }
]
```

**前端**：`contacts_ui.html:489` `loadProducts()` → `renderProductFilter()`  
**後端**：`api/routes/products.py:59` `list_products()`  
**資料來源**：⚠️ 目前為 hardcode mock（`products.py:6-55`），非 DB

---

### A3 · 篩選器（Filter Bar）

**觸發時機**：任何下拉/選項改變時

```
GET /contacts?status=&segment_tag=&product_tag=&search_tag=&limit=50
```

| Query 參數 | 型別 | 可選值 |
|---|---|---|
| `status` | string | `有效` / `Bounce` / `待確認` / `封鎖` |
| `segment_tag` | string | `潛在客戶` / `現有客戶` / `冷客戶` |
| `product_tag` | string | `Low-Dk PI` / `HDI` / `凡立水` / `FIW` |
| `search_tag` | string | 域名字串（如 `foxconn.com`） |
| `limit` | int | 預設 `50`；有文字搜尋時為 `200` |

**前端**：`contacts_ui.html:989` `fetchContacts()`  
**後端**：`api/routes/contacts.py:44` `list_contacts()`  
**DB 查詢**：`SELECT * FROM contacts WHERE ... LIMIT %s`

> 文字搜尋框（公司/聯絡人/Email）為 client-side 過濾，不重新呼叫 API  
> `search_tag` 下拉選項從 `allData[].search_tag` client-side 聚合（無 API 呼叫）

---

### A5 · 掃描 Bounce 按鈕

```
POST /contacts/scan-bounce
Body: (無)
```

**執行流程**：`IMAP 連線` → `掃描退信` → `比對 email` → `UPDATE contacts`

**回傳**
```json
{
  "scanned": 38,
  "bounced_found": 5,
  "updated": 3,
  "details": [{ "email": "...", "company": "..." }],
  "ran_at": "2026-05-05T06:00:00Z"
}
```

**DB 寫入**
```sql
UPDATE contacts
SET status = 'Bounce', bounce_reason = '信箱不存在', updated_at = NOW()
WHERE email = ? AND status = '有效'
```

**前端**：`contacts_ui.html:1120` `runBounceScan()`  
**後端**：`api/routes/contacts.py:85` `trigger_bounce_scan()`  
**腳本**：`scripts/scan_bounce_contacts.py:74` `connect_imap()` / `:114` `extract_bounced_emails()` / `:182` `mark_bounce_in_mysql()`

> 完成後自動觸發 A3 `fetchContacts()` + A1 `updateStats()`

---

### A6 · 匯出 CSV 按鈕

```
GET /contacts/export?status=&segment_tag=&product_tag=&search_tag=
```

**帶入目前篩選條件**（與 A3 相同 params）  
**回傳**：CSV 檔案下載（UTF-8 with BOM，filename: `contacts_YYYYMMDD.csv`）

**欄位順序**：`company_name, contact_name, title, email, phone, segment_tag, product_tag（/ 分隔）, search_tag（/ 分隔）, status, source_tag, website, linkedin`

**前端**：`contacts_ui.html:1091` `exportCSV()`  
**後端**：`api/routes/contacts.py:339` `export_contacts()`

---

### A7 · 匯入 CSV / XLSX 按鈕

```
POST /contacts/import
Content-Type: multipart/form-data
```

| 參數 | 位置 | 說明 |
|---|---|---|
| `file` | form-data | `.csv` 或 `.xlsx` |
| `product_tag` | query（選填） | 強制覆蓋產品標籤，如 `Low-Dk PI` |
| `search_tag` | query（選填） | 強制覆蓋搜尋標籤 |

**過濾規則**（符合任一即跳過）
1. `contact_name` 為空
2. 欄位 `郵箱狀態` ≠ `綠色`（貿小七匯出欄位名）
3. 欄位 `邮箱状态` ≠ `valid`（簡中版欄位名）

**回傳**
```json
{
  "imported": 120,
  "merged": 8,
  "skipped_dup": 30,
  "skipped_no_email": 5,
  "skipped_filter": 12,
  "imported_details": [{ "email": "...", "company": "..." }],
  "merged_details": [{ "email": "...", "company": "..." }]
}
```

**DB 寫入**
- 新聯絡人：`INSERT INTO contacts`
- 已存在：`UPDATE product_tag, search_tag`（追加，不覆蓋）

**前端**：`contacts_ui.html:1101` `importCSV()`  
**後端**：`api/routes/contacts.py:195` `import_contacts_csv()`  
**欄位別名對照**：`contacts.py:172-191` `COLUMN_ALIAS`

---

### A8 · + 聯絡人（手動新增 Modal）

```
POST /contacts
Content-Type: application/json
```

**Request Body**
```json
{
  "email": "ming@example.com",
  "company_name": "鴻海精密",
  "contact_name": "王小明",
  "title": "採購經理",
  "phone": "",
  "segment_tag": "潛在客戶",
  "source_tag": "CSV手動"
}
```

**DB 寫入**：`INSERT INTO contacts`，`status` 預設 `有效`，`email` 小寫處理

**前端**：`contacts_ui.html:1080` `submitAdd()`  
**後端**：`api/routes/contacts.py:410` `create_contact()`

> Email 已存在時回傳 `409 Conflict`

---

### A9 · 封鎖按鈕（每列）

```
DELETE /contacts/:id
```

**DB 寫入**：軟刪除，不實際移除記錄
```sql
UPDATE contacts SET status = '封鎖', updated_at = NOW() WHERE id = ?
```

**前端**：`contacts_ui.html:1068` `softDelete(id, name)`  
**後端**：`api/routes/contacts.py:484` `delete_contact()`

---

## Frame B — 貿小七搜尋頁

### B1 · 產品按鈕渲染

**觸發時機**：頁面初始化，與 A2 共用同一次 `loadProducts()` 呼叫

```
GET /products
```

**前端**：`contacts_ui.html:489` `loadProducts()` → `renderProductToggles()`  
**後端**：`api/routes/products.py:59` `list_products()`  
**資料結構**（每個產品）
```json
{
  "id": "low-dk-pi",
  "name": "Low-Dk PI",
  "desc": "低介電 PI 材料（Dk≈2.9, Df≈0.0014 @10GHz）",
  "status": "新產品",
  "segments": [
    { "name": "PCB 製造商（台灣）",  "keywords": "PCB manufacturer Taiwan\nFlexible PCB Taiwan\nFPC manufacturer" },
    { "name": "5G / AI 伺服器廠",   "keywords": "5G base station PCB\nAI server board manufacturer" }
  ]
}
```

---

### B2 · 目標客群 Chips — 從產品知識庫載入，點擊帶出公司名單

**渲染來源**：B1 選產品後，讀取 `product.segments[]`（來自產品知識庫）

**點擊 Chip 動作**

```
GET /products/:id
```

**預期回傳**（目前 `segments[].companies` 欄位待新增）
```json
{
  "id": "low-dk-pi",
  "segments": [{
    "name": "PCB 製造商（台灣）",
    "keywords": "PCB manufacturer Taiwan\n...",
    "companies": ["鴻海精密", "台積電", "欣興電子", ...]
  }]
}
```

**點擊後行為**：將 `segment.companies[]` 逐行填入步驟 3 textarea

**前端**：`contacts_ui.html:557` `toggleSegment()`（目前僅更新標籤名，未填公司）

> ⚠️ **待串接**（3 項）：
> 1. 產品知識庫 `segments` schema 需新增 `companies[]` 欄位
> 2. 點擊 chip → 呼叫 `GET /products/:id` → 取出對應 `segment.companies[]`
> 3. 將公司名單逐行填入步驟 3 textarea（可手動編輯）

---

### B3 · 開始搜尋按鈕

```
POST /jobs/trigger
Content-Type: application/json
```

**Request Body**
```json
{
  "keywords":    ["鴻海精密", "台積電", "日月光"],
  "mode":        "domain",
  "tag":         "Low-Dk PI",
  "product_tag": "Low-Dk PI",
  "export":      true,
  "max_pages":   1
}
```

| 欄位 | 來源 | 說明 |
|---|---|---|
| `keywords` | `kw-input` textarea | 每行一個公司名稱 |
| `mode` | 固定值 | `"domain"` |
| `tag` | `kw-tag.value` | 產品名 或 選取的客群名 |
| `product_tag` | `selectedProducts[0].name` | 產品名（寫入 contacts.product_tag） |
| `max_pages` | 固定值 | `1` |

**回傳**
```json
{ "status": "triggered", "job_id": "n8n-exec-id", "keywords_count": 3 }
```

**API 行為**：將 body 轉發至 `N8N_SUBMIT_URL`（env var），不直接執行腳本

**前端**：`contacts_ui.html:625` `startSearch()`  
**後端**：`api/routes/jobs.py:121` `trigger_job()`

**送出後前端動作**
1. `clearSearchForm()` — 清空步驟 1/2/3
2. `pendingQueue.push({ keywords, product_tag })` — 加入排隊
3. 3 秒後開始輪詢（進入 Frame C 邏輯）

---

## Frame C — 任務進度

### C1 · 任務狀態輪詢

**分兩階段**

#### 階段 1：等待 Windows 接手（n8n job → MySQL job）

```
GET /jobs?limit=1   // 每 5 秒
```

**判斷**：`latest.id !== preExistingJobId` → 新 job 出現 → 進入階段 2

#### 階段 2：精確追蹤

```
GET /jobs/:id   // 每 3 秒，直到 status = 'done' | 'failed'
```

**Job 物件欄位說明**

| 欄位 | 型別 | 說明 |
|---|---|---|
| `status` | string | `pending` → `running` → `downloading` → `importing` → `done` / `failed` |
| `done_count` | int | 目前完成幾家公司 |
| `current_keyword` | string | 目前正在搜尋的公司名 |
| `added_count` | int | 本次加入線索池幾筆 |
| `result` | object | `{ imported, merged, skipped_filter, skipped_dup }` |
| `error` | string | 錯誤訊息（null = 無錯誤） |

**前端**：`contacts_ui.html:678` `pollLatestJob()`  
**後端**：`api/routes/jobs.py:162` `get_job()` / `:150` `list_jobs()`  
**腳本端更新**：`PATCH /jobs/:id`（由 maoxiaoqi_batch_search.py 呼叫）

**完成後自動觸發**
- `fetchContacts()` → 重新載入聯絡人列表
- `updateStats()` → 更新統計數字
- `loadJobHistory()` → 更新搜尋歷史

> ⚠️ **殘留偵測**：`updated_at > 2 小時前` 且 `status` 仍為 `running/pending` → 前端自動顯示「已中斷」，不再輪詢

---

### C2 · 子任務列表（純前端渲染）

**無獨立 API 呼叫**，資料來自 C1 的 `GET /jobs/:id` 回傳

**狀態判斷邏輯**
```
index < done_count           → "已完成"（綠色）
kw === current_keyword       → "進行中"（紫色高亮底色）
index >= done_count          → "未開始"（灰色）
```

**前端**：`contacts_ui.html:780` `renderKeywordTable()`

---

### C3 · 搜尋歷史

```
GET /jobs?limit=5
```

**觸發時機**：頁面載入 + 每次任務完成後重新載入

| 顯示欄位 | 來源欄位 | 說明 |
|---|---|---|
| 日期 | `job.created_at` | UTC → 本地時間格式化 |
| 產品標籤 | `job.tag` | 送出時的 `kw-tag.value` |
| 公司列表 | `job.keywords[]` | 超過 2 家顯示「X 等 N 家」 |
| 狀態 | `job.status` | 殘留超過 2 小時 → 顯示「已中斷」 |
| 加入筆數 | `job.added_count` | 線索池新增筆數 |

**展開詳情**：顯示每家公司的完成狀態 + 匯入摘要（imported / merged / skipped）

**前端**：`contacts_ui.html:943` `loadJobHistory()` / `:862` `renderJobHistory()`  
**後端**：`api/routes/jobs.py:150` `list_jobs()`

---

## 背景執行流程（Windows）

> 此流程在使用者按下「開始搜尋」後，由 n8n 在 Windows 公司電腦上觸發，前端透過輪詢感知進度。

```
1. POST /jobs/trigger
   └─ api/routes/jobs.py:121  trigger_job()
      └─ POST N8N_SUBMIT_URL (env var)

2. n8n Webhook 接收
   └─ 呼叫 maoxiaoqi_batch_search.py

3. 腳本：對每家公司執行
   a. domain_lookup.py:185  lookup(company_name)
      ├─ 查 altra_agent.domain_cache（快取命中直接返回）
      └─ 未命中 → Tavily API → 取得主網域 → 寫入快取
         scripts/domain_lookup.py:128  _search_domain()

   b. maoxiaoqi_batch_search.py:345  do_search(page, domain, mode="domain")
      └─ Playwright 操作貿小七域名搜尋欄位

   c. maoxiaoqi_batch_search.py:419  select_all_and_save()
      └─ 全選線索 → 加入線索池（帶 tag）

   d. maoxiaoqi_batch_search.py:540  export_clue_pool()
      └─ 匯出 CSV 至本機 temp 目錄

   e. maoxiaoqi_batch_search.py:647  import_to_mysql()
      └─ POST /contacts/import (帶 product_tag)
         → api/routes/contacts.py:195  import_contacts_csv()

   f. PATCH /jobs/:id 更新進度（done_count, current_keyword, status）
      → api/routes/jobs.py:81  update_job()
```

**環境需求（Windows）**

| 項目 | 說明 |
|---|---|
| `TAVILY_API_KEY` | `.env` 必須設定，domain lookup 才能運作 |
| `N8N_SUBMIT_URL` | `.env` 必須設定，觸發功能才能運作 |
| Chrome User Data | `C:/Users/Leo.Hsu/AppData/Local/Google/Chrome/User Data/Default` |
| MySQL | 192.168.0.127，`altra_remote` 帳號 |

---

## 待串接項目

### ① GET /products 真實化

| 項目 | 說明 |
|---|---|
| 現況 | Hardcode mock data in `api/routes/products.py:6-55` |
| 影響範圍 | A2（篩選下拉）、B1（產品按鈕）、B2（客群 chips） |
| 待決策 | 是否移入 MySQL？需要 `POST/PATCH /products` 管理介面嗎？ |

### ② 客群點擊 → 關鍵字自動填入步驟 3

| 項目 | 說明 |
|---|---|
| 現況 | `toggleSegment()` 只更新 `kw-tag.value`（標籤名），不影響 textarea |
| 待做 | 點擊客群 chip → 將 `segment.keywords` 逐行填入步驟 3 textarea |
| Source | `contacts_ui.html:557` `toggleSegment()` |
| 備注 | 原有 `addSegmentKeywords()` (`L570`) 已有邏輯可參考，但目前未被呼叫 |

### ③ 依產品篩選對應公司聯絡人

| 項目 | 說明 |
|---|---|
| 現況 | `GET /contacts?product_tag=Low-Dk PI` 已可用 |
| 待做 | UI 加入「依產品看對應聯絡人」視圖，顯示「此產品有 N 家公司、N 筆聯絡人」 |
| 影響範圍 | A3 現有篩選 + 新 UI 元件 |

---

## 資料庫 Schema 快速參考

### `altra_marketing.contacts`

| 欄位 | 型別 | 說明 |
|---|---|---|
| `id` | VARCHAR UUID | 主鍵 |
| `company_name` | VARCHAR | 公司名稱 |
| `contact_name` | VARCHAR | 姓名 |
| `email` | VARCHAR | 唯一鍵，小寫存儲 |
| `title` | VARCHAR | 職稱 |
| `phone` | VARCHAR | 電話 |
| `website` | VARCHAR | 網址 |
| `linkedin` | VARCHAR | LinkedIn URL |
| `industry` | VARCHAR | 產業別 |
| `country` | VARCHAR | 國家，預設 `台灣` |
| `source_tag` | VARCHAR | `貿小七` / `Bitrix24` / `CSV手動` |
| `segment_tag` | VARCHAR | `潛在客戶` / `現有客戶` / `冷客戶` |
| `product_tag` | JSON | `["Low-Dk PI", "HDI"]`（多選，JSON 陣列） |
| `search_tag` | VARCHAR | 逗號分隔域名字串，如 `foxconn.com,tsmc.com` |
| `status` | VARCHAR | `有效` / `Bounce` / `待確認` / `封鎖` |
| `bounce_reason` | VARCHAR | `信箱不存在` / `拒收` |
| `last_contacted` | DATE | 最後聯繫日期 |
| `edm_sent_count` | INT | EDM 累計寄送次數 |
| `edm_last_sent_at` | DATE | 最後寄送日期 |
| `edm_last_campaign` | VARCHAR | 最後寄送主題 |
| `created_at` | DATETIME | 建立時間（UTC） |
| `updated_at` | DATETIME | 最後更新時間（UTC） |
| `bitrix24_id` | VARCHAR | 對應 Bitrix24 ID（可為空） |

### `altra_marketing.jobs`

| 欄位 | 型別 | 說明 |
|---|---|---|
| `id` | VARCHAR UUID | 主鍵 |
| `type` | VARCHAR | `maoxiaoqi_search` |
| `status` | VARCHAR | `pending` / `running` / `downloading` / `importing` / `done` / `failed` |
| `keywords` | JSON | `["鴻海精密", "台積電"]` |
| `mode` | VARCHAR | `company` / `domain` / `product` |
| `tag` | VARCHAR | 搜尋標籤（產品名或客群名） |
| `product_tag` | VARCHAR | 寫入 contacts 的產品標籤 |
| `total` | INT | 總公司數 |
| `done_count` | INT | 已完成公司數 |
| `current_keyword` | VARCHAR | 目前處理的公司名 |
| `added_count` | INT | 加入線索池筆數 |
| `result` | JSON | `{ imported, merged, skipped_filter, skipped_dup }` |
| `error` | TEXT | 錯誤訊息 |
| `triggered_by` | VARCHAR | `script` / `ui` / `n8n` |
| `created_at` | DATETIME | UTC |
| `updated_at` | DATETIME | UTC |

### `altra_agent.domain_cache`

| 欄位 | 型別 | 說明 |
|---|---|---|
| `company_name` | VARCHAR | 主鍵 |
| `domain` | VARCHAR | 主網域，如 `foxconn.com` |
| `source` | VARCHAR | `tavily` / `manual` |
| `created_at` | DATETIME | UTC |

---

*Altra Tech 數位部 / AI 部 · 2026-05-05*
