# SSP 文件解析器 前端整合規格

> **文件版本**：1.0
> **撰寫日期**：2026-04-30
> **文件性質**：FE 整合規格 — Vue 元件樹、Pinia store、wizard 細部 UI、API 契約對接
> **上游**：[`api-spec.md`](./api-spec.md)（SA） + [`design.md`](./design.md)（SD）
> **目標 repo**：`~/Projects/Billows/Audit-Manager/compliance-manager-fe/`
>
> **設計守則**：
> 1. 沿用既有 `SspImportDialog.vue` / `ModuleFrameTemplateImportDialog.vue` 的 3 步驟 wizard pattern
> 2. TypeScript + `<script setup>` + Vue 3 Composition API
> 3. PrimeVue 元件優先（FileUpload / Dropdown / Dialog / DataTable / Toast）
> 4. 元件不直接 fetch — 統一走 `Service` 層繼承 `BaseService.js`
> 5. 寫入 API 失敗用 `useToast()` 顯示錯誤；不打斷 UI

---

## 1. 頁面總覽

| # | 元件 / 頁面 | 職責 | 新增/修改 |
|---|------------|------|---------|
| 1 | `SspDocxImportDialog.vue` | 3 步驟 wizard 主容器 | **新增** |
| 2 | `ssp-docx-import/StepUpload.vue` | Step 0 — 上傳檔案 + framework dropdown | 新增 |
| 3 | `ssp-docx-import/StepPreview.vue` | Step 1 — 預覽編輯（核心 UI） | 新增 |
| 4 | `ssp-docx-import/StepResult.vue` | Step 2 — 結果摘要 | 新增 |
| 5 | `ssp-docx-import/MatchedControlDiff.vue` | 單一控制項的 diff 比較行 | 新增 |
| 6 | `ssp-docx-import/UnmatchedParagraphCard.vue` | 單一未匹配段落（可拖拉） | 新增 |
| 7 | `ssp-docx-import/DropTargetTree.vue` | 接收拖拉的控制項 / objective tree | 新增 |
| 8 | `ssp-docx-import/ConflictModal.vue` | 拖拉到已有內容時的 append/replace 詢問 | 新增 |
| 9 | `ssp-docx-import/BatchActionsToolbar.vue` | 全部 docx / 保留 / 只填空 批次按鈕 | 新增 |
| 10 | `ssp-docx-import/ParseWarningBanner.vue` | PARTIAL warning 顯示 | 新增 |
| 11 | `ssp-docx-import/PredictedControlsSelector.vue` | mode=full 時的控制項預選清單（可微調） | 新增 |
| 12 | `service/SspDocxImportService.js` | API 呼叫服務層 | 新增 |
| 13 | `stores/sspDocxImport.js` (Pinia) | wizard state + decisions 累積 | 新增 |
| 14 | `ModuleFrame.vue` Step 2 / Step 3 | 加「從 docx 匯入」按鈕觸發 SspDocxImportDialog | **修改** |
| 15 | SSP 控制項現況頁 | 加「匯入 docx」按鈕（與「匯入 Excel」並排） | 修改 |
| 16 | `config/api/api.js` | 新增 4 條 endpoint 常數 | 修改 |
| 17 | `config/locales/i18n/zh-tw/ssp_docx_import.json` | 新增 i18n 字串 | 新增 |
| 18 | `config/locales/i18n/en/ssp_docx_import.json` | 新增 i18n 字串（英文） | 新增 |

---

## 2. 入口整合

### 2.1 SSP 控制項現況頁（入口 B）

頁面位置：根據既有路徑，推測在 `src/views/grc/project-planning/SspControlImplementationView.vue` 或類似。

**修改**：在「匯入 Excel」按鈕旁加「匯入 docx」按鈕：

```vue
<div class="ssp-import-actions">
  <Button label="匯入 Excel"
          icon="pi pi-file-excel"
          @click="openExcelDialog" />
  <Button label="匯入 docx"
          icon="pi pi-file-word"
          severity="secondary"
          @click="openDocxDialog" />  <!-- 新增 -->
</div>

<SspImportDialog v-model:visible="excelDialogVisible" :ssp-uid="sspUid" @imported="onImported" />

<!-- 新增 -->
<SspDocxImportDialog
  v-model:visible="docxDialogVisible"
  source-type="project_ssp"
  :source-uid="sspUid"
  mode="statement_only"
  @imported="onImported"
/>
```

按鈕並排，使用者選格式。Excel 與 docx 兩套並存。

### 2.2 ModuleFrame.vue Step 2（入口 A1）

**修改既有 `ModuleFrame.vue`**（編輯/新增 wizard 第 2 步「選控制項」）：

頂部加「從 docx 匯入控制項」按鈕：

```vue
<!-- Step 2 — 選控制項 -->
<div class="step-2-toolbar">
  <Button label="從 docx 匯入控制項"
          icon="pi pi-file-word"
          severity="secondary"
          size="small"
          @click="openDocxImport('full')" />
</div>

<!-- 既有的手動勾選清單 ... -->

<!-- 新增 -->
<SspDocxImportDialog
  v-model:visible="docxDialogVisible"
  source-type="module_frame"
  :source-uid="moduleFrameUid"
  :mode="docxMode"
  @imported="onDocxImported"
/>
```

`onDocxImported(payload)` 收到 payload 後：
- 若 mode='full'：把 `predicted_controls_user_selection` 套到本頁的 `selectedControls` array，使用者看到自動勾選結果，可微調
- 若 mode='statement_only'（從 Step 3 觸發）：把現況資料寫入暫存，Step 3 顯示

### 2.3 ModuleFrame.vue Step 3（入口 A2）

同 Step 2 模式，按鈕 label 改為「從 docx 匯入現況」，呼叫 `openDocxImport('statement_only')`。

---

## 3. SspDocxImportDialog.vue（top-level 3-step wizard）

### 3.1 Props / Emits

```ts
interface Props {
  visible: boolean
  sourceType: 'project_ssp' | 'module_frame'
  sourceUid: string
  mode: 'full' | 'statement_only'
}

interface Emits {
  'update:visible': [value: boolean]
  'imported': [payload: ImportResultPayload]
}
```

### 3.2 內部 state（Composition API）

```ts
const step = ref<0 | 1 | 2>(0)
const parseUid = ref<string | null>(null)
const importStore = useSspDocxImportStore()  // Pinia
```

> **為什麼用 Pinia store**：Step 1 的子元件數量多（matched list / unmatched list / drop targets / batch toolbar），用 props/emits 傳資料會 prop drilling。store 封裝較乾淨。Store 的 lifecycle 與 dialog 綁（dialog open 時 reset，close 時 clear）。

### 3.3 Layout

```
┌────────────────────────────────────────────────────────────┐
│ Dialog header: 從 docx 匯入 SSP 控制項現況                   │
├────────────────────────────────────────────────────────────┤
│ Stepper:  ●─────○─────○                                     │
│           Upload  Preview  Result                            │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  <component :is="currentStepComponent" />                   │
│                                                            │
├────────────────────────────────────────────────────────────┤
│ Footer: [上一步]  [下一步 / 確認匯入 / 完成]                  │
└────────────────────────────────────────────────────────────┘
```

PrimeVue `<Stepper>` + `<StepperPanel>` 顯示進度。

---

## 4. Step 0 — Upload (`StepUpload.vue`)

### 4.1 UI 結構

```
┌────────────────────────────────────────────────────────────┐
│ 1. 選擇合規 framework：                                       │
│    [▼ CMMC L1 ─────────────────────────]                    │
│                                                            │
│ 2. 上傳 docx：                                               │
│    ┌──────────────────────────────┐                        │
│    │  Drag .docx here, or click   │                        │
│    │       [Choose File]           │                        │
│    └──────────────────────────────┘                        │
│    [ASIA-CMMC-SSP-DRAFT-202604.docx]  5.3 MB                │
└────────────────────────────────────────────────────────────┘
```

### 4.2 元件配置

- **Framework**：`<Dropdown>` + 選項從 props.sourceType 過濾
  - 入口 A1（mode=full）：所有支援的 framework
  - 入口 A2 / B：以 source 對應 baseline 的 framework 為預設值（可改）
- **FileUpload**：`<FileUpload>` mode="basic"，accept=".docx"，maxFileSize=10485760 (10MB)

### 4.3 Validation

- framework 未選 → disable「下一步」
- 檔案未選 → disable「下一步」
- 檔案大小 > 10MB → 立即顯示錯誤訊息（PrimeVue Toast severity='warn'）
- 檔案副檔名 != `.docx` → 拒絕（PrimeVue FileUpload 內建 reject hook）

### 4.4 點「下一步」

```ts
async function onNext() {
  loading.value = true
  try {
    const res = await sspDocxImportService.parse({
      file: selectedFile.value,
      framework: framework.value,
      source_type: props.sourceType,
      source_uid: props.sourceUid,
      mode: props.mode,
    })
    // 成功 (200)
    parseUid.value = res.data.parse_uid
    importStore.setSummary(res.data.summary)
    if (res.data.summary.warning_level === 'partial') {
      importStore.setWarning(res.data.summary.warnings)
    }
    // 進 Step 1
    step.value = 1
    await importStore.loadPreview(parseUid.value)  // 呼叫 GET /<uid>
  } catch (err) {
    // FATAL 422 / 400 — 顯示錯誤訊息
    handleParseError(err)
  } finally {
    loading.value = false
  }
}
```

`handleParseError` 依 `error_code` 顯示對應 i18n 訊息（FATAL 三種：no_control_id / mismatch / parse_failed），使用者只能關閉 dialog 重來。

---

## 5. Step 1 — Preview (`StepPreview.vue`)（核心 UI）

### 5.1 整體 Layout

```
┌─────────────────────────────────────────────────────────────────┐
│ ⚠ PARTIAL Warning Banner（若有）                                  │
│   規則命中率 < 50% — 建議檢查 framework 選擇是否正確                  │
├─────────────────────────────────────────────────────────────────┤
│ 批次操作 toolbar                                                  │
│ [全部使用 docx] [全部保留現有] [只匯入空白項] [篩選: ▼ 全部]            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│ 【已自動匹配】(22 個控制項)                                        │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ▾ AC.L1-b.1.i — Authorized Access Control  信心 95%          │ │
│ │   ┌─ 主述 ────────────────────────────────────────────────┐ │ │
│ │   │ [現有] 我們透過 AD 控制使用者群組...                       │ │ │
│ │   │ [docx] 我們透過 AD + Okta 控制使用者群組，並...             │ │ │
│ │   │ ◯ 保留 ●  使用 docx ◯ 不匯入                            │ │ │
│ │   └─────────────────────────────────────────────────────┘ │ │
│ │   ┌─ Objective (a) — authorized users are identified ────┐   │ │
│ │   │ [現有] 已透過 SOP-001 識別                              │   │ │
│ │   │ [docx] 已透過 SOP-001 識別並由 IT 部審核                  │   │ │
│ │   │ ◯ 保留 ●  使用 docx ◯ 不匯入                          │   │ │
│ │   └────────────────────────────────────────────────┘   │ │
│ │   ▸ Objective (b) ... (collapsed)                         │ │
│ └─────────────────────────────────────────────────────────────┘ │
│                                                                 │
│ ▾ AC.L1-b.1.ii — ... (collapsed)                                │
│                                                                 │
├─────────────────────────────────────────────────────────────────┤
│ 【待人工確認】(8 段)                                               │
│   左欄：未匹配段落卡片（可拖拉）                                      │
│   ┌─ Para #142 ─────────────┐                                   │
│   │ 我們的安全事件處理由 SOC..│                                   │
│   │ 系統猜測：IR.L2-? (60%)  │                                   │
│   │ ◇ Drag to assign ────→   │                                   │
│   │ [不匯入此段]              │                                   │
│   └────────────────────────┘                                   │
│                                                                 │
│   右欄：Drop target tree                                          │
│   ▾ AC.L1-b.1.i — 授權存取控制 ◇ Drop here (主述)                 │
│       ◇ Objective (a) — Drop here                               │
│       ◇ Objective (b) — Drop here                               │
│   ▾ AC.L1-b.1.ii — ...                                          │
│                                                                 │
├─────────────────────────────────────────────────────────────────┤
│ ⓘ 以下控制項在 baseline 內但 docx 未提及（將留空）：                  │
│   - SC.L1-b.1.x  Boundary Protection                              │
│   - SI.L1-b.1.xv System & File Scanning                           │
└─────────────────────────────────────────────────────────────────┘
```

### 5.2 區塊拆解

#### 5.2.1 ParseWarningBanner.vue
- Props: `level: 'partial'`, `warnings: string[]`
- PrimeVue `<Message>` severity='warn'
- 可關閉（但不影響後續流程，使用者繼續匯入）

#### 5.2.2 BatchActionsToolbar.vue
- 三按鈕：全部使用 docx / 全部保留現有 / 只匯入空白項
- emit `batch-action`，store 內遍歷 matched_controls + objectives 套 default_action
- 篩選 dropdown：全部 / 僅有 conflict / 僅有空白

#### 5.2.3 已自動匹配清單（MatchedControlsList）
- 每個 control 用 `<Accordion>` 可摺疊
- 一行 control 含：control_id、name、score、主述 diff、各 objective diff
- 主述 diff = `MatchedControlDiff.vue`，objective diff = 同元件複用
- 一行右下角的 radio button 群組（保留/使用 docx/不匯入）

`MatchedControlDiff.vue`：
- Props: `currentValue: string | null`, `parsedValue: string | null`, `defaultAction: 'use_docx' | 'keep_current' | 'skip'`
- 內部 state `selectedAction`，emit `update`
- 視覺：
  - 兩段並排顯示，相異字元 highlight（`<diff-match-patch>` 或簡單行 diff）
  - radio 預設選 default_action
  - 若 currentValue == parsedValue → 不顯示（store 過濾掉）

#### 5.2.4 待人工確認清單（左欄 source / 右欄 target）

`UnmatchedParagraphCard.vue`：
- Props: `paragraph: { idx, text, context, rule_score, rule_guess_control_id }`
- 視覺：
  - 段落文字（前 200 字 + 「展開」按鈕）
  - 上方 heading context（badge）
  - rule_guess（若有）顯示「系統猜測：XX (60%)」
  - 標記為 draggable（v-draggable directive 或 vuedraggable wrapper）
  - 「不匯入此段」按鈕：點擊後 store 移到 skipped_paragraphs，視覺消失或移到「已忽略」摺疊區

`DropTargetTree.vue`：
- 顯示完整 baseline 控制項清單（分群 by family code）
- 每個 control 一個 drop zone（implementation 層）
- 每個 control 下展開 objective drop zones（(a)(b)(c)...）
- v-droppable directive，drop 時觸發 `assignTo(paragraph_idx, control_id, level, objective_key?)`
- 視覺反饋：drag over 時 highlight 邊框

#### 5.2.5 ConflictModal.vue
- 拖拉到「已有 docx 內容」的控制項時跳出
- Props: `currentValue`, `incomingValue`, `controlId`, `level`, `objectiveKey?`
- 兩個按鈕：「Append（加在後面）」/「Replace（取代）」
- 確認後 emit `confirm: { merge_action: 'append' | 'replace' }`
- 取消（按 X）→ 拖拉操作不生效

#### 5.2.6 PredictedControlsSelector.vue（僅 mode=full）

入口 A1 專用，顯示在 Matched/Unmatched 之上：

```
┌─────────────────────────────────────────────────────────────────┐
│ 【將要設為適用控制項】(預選 22 個，您可微調)                            │
│ ☑ AC.L1-b.1.i  授權存取控制（docx 偵測）                              │
│ ☑ AC.L1-b.1.ii Transaction & Function Control                      │
│ ☐ AC.L1-b.1.iii Internal System Connections（docx 沒提）             │
│ ☑ AC.L1-b.1.iv ...                                                  │
│                                                                  │
│ Total: 22 已選 / 17 framework 全部                                  │
└─────────────────────────────────────────────────────────────────┘
```

- Checkbox 列表
- 預設勾選 = parsed_result.predicted_module_frame_controls
- 使用者可勾 / 取消
- 標 「docx 偵測」/ 「未在 docx 出現」 區別

### 5.3 Drag-Drop 技術選型

**選用 vuedraggable**（基於 SortableJS）：
- 已有專案使用記錄（檢查 package.json）— 若無則 `npm i vuedraggable@next`
- 支援巢狀群組（拖出 / 拖入）
- 也可考慮 PrimeVue OrderList，但客製性較低
- 自訂 `dragClass` / `ghostClass` 視覺反饋

實作關鍵：
- 拖拉源：`UnmatchedParagraphCard`，`group="paragraphs"`
- 拖拉目標：`DropTargetTree` 的每個 drop zone，`group="paragraphs"` + `:list="emptyArray"` 阻止實際 push（只觸發 `@change`）
- onChange handler 接 `(event) => onAssign(event.added.element)`

### 5.4 點「上一步」/ 「確認匯入」

- 上一步：清空 importStore + 回到 Step 0（warning：「將清除所有編輯的決策」 → 使用者確認）
- 確認匯入：
  ```ts
  async function onConfirm() {
    confirmLoading.value = true
    try {
      const decisions = importStore.buildDecisionsPayload()  // store 組好 payload
      const res = await sspDocxImportService.confirm(parseUid.value, decisions)
      importStore.setResult(res.data.import_summary)
      step.value = 2
    } catch (err) {
      handleConfirmError(err)
    } finally {
      confirmLoading.value = false
    }
  }
  ```

---

## 6. Step 2 — Result (`StepResult.vue`)

### 6.1 UI

```
┌────────────────────────────────────────────────────────────┐
│  ✓ 匯入完成                                                  │
│                                                            │
│  • 新建：22 筆                                              │
│  • 更新：5 筆                                               │
│  • 略過（保留現有）：3 筆                                     │
│  • 拖拉指派寫入：8 筆                                         │
│  • 標記不匯入：3 筆                                          │
│  • Baseline 缺漏（已留空）：3 筆                              │
│                                                            │
│  [完成]                                                     │
└────────────────────────────────────────────────────────────┘
```

點「完成」：`emit('imported', summary)` + `emit('update:visible', false)`，dialog 關閉，呼叫端頁面 reload。

---

## 7. State Management（Pinia）

### 7.1 Store: `stores/sspDocxImport.js`

```js
import { defineStore } from 'pinia'

export const useSspDocxImportStore = defineStore('sspDocxImport', {
  state: () => ({
    parseUid: null,
    summary: null,
    warningLevel: null,
    warnings: [],

    // From GET preview
    matchedControls: [],
    unmatchedParagraphs: [],
    missingBaselineControls: [],
    predictedControls: [],

    // User decisions (built up in Step 1)
    decisionByControlId: {},      // { control_id: { action, objectives: { (a): action, (b): action } } }
    manualAssignments: {},        // { paragraph_idx: [{ control_id, level, objective_key?, merge_action }] }
    skippedParagraphIdxs: new Set(),
    predictedControlsUserSelection: [],  // mode=full only

    // Step 2 result
    importSummary: null,
  }),

  actions: {
    async loadPreview(parseUid) {
      const res = await sspDocxImportService.getPreview(parseUid)
      // populate matchedControls, unmatchedParagraphs, etc.
      // initialize decisionByControlId with default_action
    },

    setControlAction(controlId, action) { ... },
    setObjectiveAction(controlId, objectiveKey, action) { ... },

    assignParagraph(paragraphIdx, target) { ... },  // target: { control_id, level, objective_key?, merge_action }
    unassignParagraph(paragraphIdx, controlId) { ... },

    skipParagraph(paragraphIdx) { ... },
    unskipParagraph(paragraphIdx) { ... },

    batchAction(action) {
      // 套用 'all_use_docx' / 'all_keep_current' / 'fill_blanks_only'
    },

    togglePredictedControl(controlId) { ... },

    buildDecisionsPayload() {
      // Build the payload for POST /confirm
      return {
        decisions: Object.values(this.decisionByControlId),
        manual_assignments: Object.entries(this.manualAssignments).map(...),
        skipped_paragraph_idxs: Array.from(this.skippedParagraphIdxs),
        ...(this.mode === 'full' && { predicted_controls_user_selection: this.predictedControlsUserSelection }),
      }
    },

    reset() { /* ... */ },
  }
})
```

### 7.2 Store lifecycle

- Dialog `mounted` 時 `reset()`
- Step 0 → Step 1：填 summary、parseUid，呼叫 `loadPreview`
- Step 2 完成：填 importSummary
- Dialog `unmounted` 或 close：再 `reset()`

---

## 8. Service Layer

### 8.1 `service/SspDocxImportService.js`

```js
import BaseService from './BaseService'
import { API } from '@/config/api/api'

class SspDocxImportService extends BaseService {
  /** POST /api/1.0/ssp-docx-imports/parse */
  parse({ file, framework, source_type, source_uid, mode }) {
    const formData = new FormData()
    formData.append('file', file)
    formData.append('framework', framework)
    formData.append('source_type', source_type)
    formData.append('source_uid', source_uid)
    formData.append('mode', mode)
    return this.post(API.SSP_DOCX_IMPORT_PARSE, formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
    })
  }

  /** GET /api/1.0/ssp-docx-import/<parse_uid> */
  getPreview(parseUid) {
    return this.get(`${API.SSP_DOCX_IMPORT}/${parseUid}`)
  }

  /** POST /api/1.0/ssp-docx-import/<parse_uid>/confirm */
  confirm(parseUid, payload) {
    return this.post(`${API.SSP_DOCX_IMPORT}/${parseUid}/confirm`, payload)
  }

  /** DELETE /api/1.0/ssp-docx-import/<parse_uid> */
  discard(parseUid) {
    return this.delete(`${API.SSP_DOCX_IMPORT}/${parseUid}`)
  }
}

export default new SspDocxImportService()
```

### 8.2 API constants — `config/api/api.js`

新增（與既有 `SSP_IMPORT_*` 並排）：

```js
SSP_DOCX_IMPORT_PARSE:   getUrl('/ssp-docx-imports/parse'),       // POST multipart
SSP_DOCX_IMPORT:         getUrl('/ssp-docx-import'),               // + /:uid          GET / DELETE
                                                                   // + /:uid/confirm  POST
```

---

## 9. i18n 字串清單

### 9.1 新增 `i18n/zh-tw/ssp_docx_import.json`

```json
{
  "ssp_docx_import": {
    "dialog_title": "從 docx 匯入 SSP 控制項現況",
    "dialog_title_full": "從 docx 匯入控制項清單與現況",

    "step_upload": "上傳檔案",
    "step_preview": "預覽編輯",
    "step_result": "結果",

    "framework_label": "合規 Framework",
    "framework_required": "請先選擇合規 framework",
    "file_upload_hint": "拖拉 .docx 檔案到此或點擊選擇（≤10MB）",
    "file_too_large": "檔案大小超過 10MB",
    "file_invalid_format": "請上傳 .docx 格式檔案",

    "parsing": "解析中...",
    "fatal_no_control_id": "docx 內無法辨識任何控制項 ID，請確認檔案內容",
    "fatal_framework_mismatch": "docx 內主要控制項與選擇的 framework 不符，請確認 framework 選擇",
    "fatal_parse_failed": "docx 解析失敗，檔案可能損壞",

    "warning_partial_match_low": "規則命中率偏低，建議檢查 framework 選擇是否正確",
    "warning_partial_baseline_missing": "Baseline 中 {n} 個控制項未在 docx 找到",

    "matched_controls_title": "已自動匹配（{n} 個控制項）",
    "unmatched_paragraphs_title": "待人工確認（{n} 段）",
    "missing_controls_title": "Baseline 中 docx 未提及的控制項（將留空）",
    "predicted_controls_title": "將要設為適用控制項（{selected} 已選 / {total} framework 全部）",

    "score_high": "信心 {score}% — H3 標題完整匹配",
    "score_medium": "信心 {score}% — 文字匹配",

    "diff_existing": "現有",
    "diff_docx": "docx",
    "action_keep_current": "保留現有",
    "action_use_docx": "使用 docx",
    "action_skip": "不匯入",

    "drag_to_assign": "拖拉指派",
    "drop_here_implementation": "拖到此處（主述）",
    "drop_here_objective": "拖到此處（Objective {key}）",
    "system_guess": "系統猜測：{control_id}（{score}%）",
    "skip_this_paragraph": "不匯入此段",

    "conflict_title": "目標已有 docx 內容",
    "conflict_message": "您要拖入的位置已有現況描述，請選擇：",
    "conflict_existing_label": "現有內容",
    "conflict_incoming_label": "拖入的內容",
    "conflict_action_append": "加在後面",
    "conflict_action_replace": "取代現有",

    "batch_all_docx": "全部使用 docx",
    "batch_all_keep": "全部保留現有",
    "batch_only_blanks": "只匯入空白項",
    "filter_label": "篩選",
    "filter_all": "全部",
    "filter_conflict_only": "僅有衝突",
    "filter_blank_only": "僅有空白",

    "result_title": "匯入完成",
    "result_created": "新建 {n} 筆",
    "result_updated": "更新 {n} 筆",
    "result_skipped": "略過（保留現有）{n} 筆",
    "result_manual_assigned": "拖拉指派寫入 {n} 筆",
    "result_manual_skipped": "標記不匯入 {n} 筆",
    "result_missing_left_blank": "Baseline 缺漏（已留空）{n} 筆",
    "result_close": "完成",

    "btn_back": "上一步",
    "btn_next": "下一步",
    "btn_confirm": "確認匯入",
    "btn_cancel": "取消",

    "back_warning_title": "確定回上一步？",
    "back_warning_message": "回上一步將清除您所有的編輯決策"
  }
}
```

### 9.2 同步加 `i18n/en/ssp_docx_import.json`（英文版，略）

---

## 10. 與既有元件互動

### 10.1 不破壞既有 SspImportDialog

- 新增 SspDocxImportDialog 是**獨立元件**，不繼承 / 不修改 SspImportDialog
- 兩個 Dialog 共存於 SSP 控制項現況頁，使用者選格式

### 10.2 ModuleFrame.vue 加按鈕但不重構

- 只新增 button + 新增 SspDocxImportDialog instance
- 既有手動勾選 / 填寫流程不動
- `onDocxImported` callback 將 store / wizard state 寫進 ModuleFrame.vue 的本地 state

### 10.3 Pinia store 命名空間隔離

- store id `sspDocxImport`，與既有 store 不衝突
- 不引用其他 store（例如不直接讀 user store；user_id 從 JWT 自動進 BE）

---

## 11. 錯誤處理 UX

### 11.1 FATAL（Step 0 → 1 之間發生）

- Toast severity='error'，訊息 i18n
- Dialog 不前進，使用者可重新選檔或關閉

### 11.2 PARTIAL（Step 0 → 1 完成，但有 warning）

- Step 1 頂部顯示 PartialWarningBanner
- 使用者可繼續或關閉 dialog 重來

### 11.3 ITEM-LEVEL（Step 1 內）

- 屬正常流程，不需 toast
- 拖拉失敗（如 objective_key 驗證錯）→ Toast severity='warn'

### 11.4 Confirm 時 422 / 412

- 412 expired → 顯示「您的 parse job 已過期，請重新上傳」+ 自動回 Step 0
- 412 invalid status → 同上
- 400 decisions 缺漏 → 顯示「請對所有 matched 控制項做選擇」+ 留在 Step 1

### 11.5 網路 / 5xx

- BaseService 統一錯誤處理 → toast 「系統錯誤，請稍後再試」

---

## 12. 拖拉指派的 UX 細節

### 12.1 視覺反饋

- **拖拉源 hover**：cursor: grab
- **拖拉中**：cursor: grabbing，源段落半透明 ghost
- **drop zone hover**：邊框變色 + 底色淡化
- **drop 成功**：源段落消失（移到「已指派」摺疊區）

### 12.2 已指派的段落可移除

`UnmatchedParagraphCard` 拖到 target 後變成「已指派 → control_id (level)」，旁邊有 X 按鈕可解除指派。

### 12.3 一段拖到多個 target

對應 SA AC-8（β 行為）：使用者可重複拖拉同一段到不同 target。store 內 `manualAssignments[paragraph_idx]` 是 array。

---

## 13. 測試 hooks（給 E2E test 用）

### 13.1 data-testid 慣例

- `data-testid="ssp-docx-import-dialog"` — 整個 dialog
- `data-testid="step-upload-framework-dropdown"` — Step 0 framework dropdown
- `data-testid="step-upload-file-input"` — FileUpload 元件
- `data-testid="matched-control-{control_id}"` — 每個 matched control row
- `data-testid="unmatched-paragraph-{idx}"` — 每個 unmatched card
- `data-testid="drop-zone-{control_id}-implementation"` / `-objective-{key}` — drop zones
- `data-testid="conflict-modal"` — conflict 視窗
- `data-testid="batch-{action}"` — 批次按鈕
- `data-testid="confirm-import-btn"` — 確認匯入按鈕

E2E（compliance-manager-test/）依此 testid 寫 Cucumber step。

---

## 14. Open Issues

| ID | 問題 | 建議 |
|----|------|------|
| **FE-OPEN-1** | Diff 顯示算法：完全顯示新舊兩段 vs 用 diff-match-patch 高亮差異？後者較複雜 | v1 用前者（兩段並排），v2 視需求加高亮 |
| **FE-OPEN-2** | 5MB docx 解析後 parsed_result 可能很大（matched_controls 含全文），Step 1 渲染 25 個 accordion 會慢嗎？ | v1 採 lazy render（accordion 展開才渲染詳細 diff），測試後若需再優化 |
| **FE-OPEN-3** | 拖拉操作的 keyboard accessibility（無滑鼠使用者）？ | v1 不做（拖拉本身就 mouse-only），v2 加 keyboard 替代方案 |
| **FE-OPEN-4** | Step 1 整體適合 mobile 嗎？拖拉在小螢幕上難用 | v1 不支援 mobile，dialog 顯示「請使用桌機」訊息 |
| **FE-OPEN-5** | 中途離開 dialog 後再開，是否要保留 store 狀態？SD 已決定「不保留」（α），需在 onClose 時 reset | 已對齊 SD §2.4 |

---

## 15. 完整檔案清單（FE 新增 / 修改）

### 新增

```
src/
├── components/grc/
│   ├── SspDocxImportDialog.vue                       (top-level)
│   └── ssp-docx-import/
│       ├── StepUpload.vue
│       ├── StepPreview.vue
│       ├── StepResult.vue
│       ├── MatchedControlDiff.vue
│       ├── UnmatchedParagraphCard.vue
│       ├── DropTargetTree.vue
│       ├── ConflictModal.vue
│       ├── BatchActionsToolbar.vue
│       ├── ParseWarningBanner.vue
│       └── PredictedControlsSelector.vue
├── service/
│   └── SspDocxImportService.js
├── stores/
│   └── sspDocxImport.js
└── config/locales/i18n/
    ├── zh-tw/ssp_docx_import.json
    └── en/ssp_docx_import.json
```

### 修改

```
src/
├── components/grc/
│   └── ModuleFrame.vue                               (Step 2 / 3 加按鈕)
├── views/grc/project-planning/
│   └── SspControlImplementationView.vue              (加「匯入 docx」按鈕)
└── config/api/api.js                                 (新增 4 個 endpoint)
```

依賴：
- `npm i vuedraggable@next`（若未安裝）
- 既有 `@/service/BaseService`、`primevue`、`vue-i18n`、`pinia` 已具備

---

## 16. 與 design.md (SD) 的對齊

| SD §  | FE 對應 |
|-------|--------|
| §1.1 Route 層 4 個 endpoint | §8.2 API constants + §8.1 service methods |
| §2.4 lazy expiration | §11.4 confirm 時 412 expired 處理 |
| §2.5 default action | §7.1 store action 初始化邏輯 |
| §2.6 manual_assignments | §12 拖拉 UX + §7.1 store assignParagraph |
| §3 Strategy pattern | FE 不感知（透明，由 source_type 決定 BE 寫入路徑） |
| §6.2 race condition v1 後者勝 | UI 不顯示 lock，使用者重複操作會看到結果但 BE 後者勝 |

---

**Sign-off 區（Phase 2.3 結束時填寫）**

| 角色 | 名字 | 日期 | 備註 |
|------|------|------|------|
| FE Lead | | | |

簽完後 Phase 2 完成，進 **Phase 3 writing-plans**（implementation-plan.md）。
