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

文件版本:1.0 撰寫日期:2026-04-30 文件性質:FE 整合規格 — Vue 元件樹、Pinia store、wizard 細部 UI、API 契約對接 上游api-spec.md(SA) + 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」按鈕:

<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 匯入控制項」按鈕:

<!-- 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

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)

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 點「下一步」

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
  }
}

handleParseErrorerror_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 視覺反饋

實作關鍵:

  • 拖拉源:UnmatchedParagraphCardgroup="paragraphs"
  • 拖拉目標:DropTargetTree 的每個 drop zone,group="paragraphs" + :list="emptyArray" 阻止實際 push(只觸發 @change
  • onChange handler 接 (event) => onAssign(event.added.element)

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

  • 上一步:清空 importStore + 回到 Step 0(warning:「將清除所有編輯的決策」 → 使用者確認)
  • 確認匯入:
    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

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 mountedreset()
  • Step 0 → Step 1:填 summary、parseUid,呼叫 loadPreview
  • Step 2 完成:填 importSummary
  • Dialog unmounted 或 close:再 reset()

8. Service Layer

8.1 service/SspDocxImportService.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_* 並排):

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

{
  "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/BaseServiceprimevuevue-i18npinia 已具備

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)。