文件版本: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/設計守則:
- 沿用既有
SspImportDialog.vue/ModuleFrameTemplateImportDialog.vue的 3 步驟 wizard pattern- TypeScript +
<script setup>+ Vue 3 Composition API- PrimeVue 元件優先(FileUpload / Dropdown / Dialog / DataTable / Toast)
- 元件不直接 fetch — 統一走
Service層繼承BaseService.js- 寫入 API 失敗用
useToast()顯示錯誤;不打斷 UI
| # | 元件 / 頁面 | 職責 | 新增/修改 |
|---|---|---|---|
| 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 字串(英文) | 新增 |
頁面位置:根據既有路徑,推測在 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 兩套並存。
修改既有 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 後:
predicted_controls_user_selection 套到本頁的 selectedControls array,使用者看到自動勾選結果,可微調同 Step 2 模式,按鈕 label 改為「從 docx 匯入現況」,呼叫 openDocxImport('statement_only')。
interface Props {
visible: boolean
sourceType: 'project_ssp' | 'module_frame'
sourceUid: string
mode: 'full' | 'statement_only'
}
interface Emits {
'update:visible': [value: boolean]
'imported': [payload: ImportResultPayload]
}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)。
┌────────────────────────────────────────────────────────────┐
│ Dialog header: 從 docx 匯入 SSP 控制項現況 │
├────────────────────────────────────────────────────────────┤
│ Stepper: ●─────○─────○ │
│ Upload Preview Result │
├────────────────────────────────────────────────────────────┤
│ │
│ <component :is="currentStepComponent" /> │
│ │
├────────────────────────────────────────────────────────────┤
│ Footer: [上一步] [下一步 / 確認匯入 / 完成] │
└────────────────────────────────────────────────────────────┘
PrimeVue <Stepper> + <StepperPanel> 顯示進度。
StepUpload.vue)┌────────────────────────────────────────────────────────────┐
│ 1. 選擇合規 framework: │
│ [▼ CMMC L1 ─────────────────────────] │
│ │
│ 2. 上傳 docx: │
│ ┌──────────────────────────────┐ │
│ │ Drag .docx here, or click │ │
│ │ [Choose File] │ │
│ └──────────────────────────────┘ │
│ [ASIA-CMMC-SSP-DRAFT-202604.docx] 5.3 MB │
└────────────────────────────────────────────────────────────┘
<Dropdown> + 選項從 props.sourceType 過濾
<FileUpload> mode="basic",accept=".docx",maxFileSize=10485760 (10MB).docx → 拒絕(PrimeVue FileUpload 內建 reject hook)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 重來。
StepPreview.vue)(核心 UI)┌─────────────────────────────────────────────────────────────────┐
│ ⚠ 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 │
└─────────────────────────────────────────────────────────────────┘
level: 'partial', warnings: string[]<Message> severity='warn'batch-action,store 內遍歷 matched_controls + objectives 套 default_action<Accordion> 可摺疊MatchedControlDiff.vue,objective diff = 同元件複用MatchedControlDiff.vue:
currentValue: string | null, parsedValue: string | null, defaultAction: 'use_docx' | 'keep_current' | 'skip'selectedAction,emit update<diff-match-patch> 或簡單行 diff)UnmatchedParagraphCard.vue:
paragraph: { idx, text, context, rule_score, rule_guess_control_id }DropTargetTree.vue:
assignTo(paragraph_idx, control_id, level, objective_key?)currentValue, incomingValue, controlId, level, objectiveKey?confirm: { merge_action: 'append' | 'replace' }入口 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 全部 │
└─────────────────────────────────────────────────────────────────┘
選用 vuedraggable(基於 SortableJS):
npm i vuedraggable@nextdragClass / ghostClass 視覺反饋實作關鍵:
UnmatchedParagraphCard,group="paragraphs"DropTargetTree 的每個 drop zone,group="paragraphs" + :list="emptyArray" 阻止實際 push(只觸發 @change)(event) => onAssign(event.added.element)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
}
}StepResult.vue)┌────────────────────────────────────────────────────────────┐
│ ✓ 匯入完成 │
│ │
│ • 新建:22 筆 │
│ • 更新:5 筆 │
│ • 略過(保留現有):3 筆 │
│ • 拖拉指派寫入:8 筆 │
│ • 標記不匯入:3 筆 │
│ • Baseline 缺漏(已留空):3 筆 │
│ │
│ [完成] │
└────────────────────────────────────────────────────────────┘
點「完成」:emit('imported', summary) + emit('update:visible', false),dialog 關閉,呼叫端頁面 reload。
stores/sspDocxImport.jsimport { 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() { /* ... */ },
}
})mounted 時 reset()loadPreviewunmounted 或 close:再 reset()service/SspDocxImportService.jsimport 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()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 POSTi18n/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": "回上一步將清除您所有的編輯決策"
}
}i18n/en/ssp_docx_import.json(英文版,略)onDocxImported callback 將 store / wizard state 寫進 ModuleFrame.vue 的本地 statesspDocxImport,與既有 store 不衝突UnmatchedParagraphCard 拖到 target 後變成「已指派 → control_id (level)」,旁邊有 X 按鈕可解除指派。
對應 SA AC-8(β 行為):使用者可重複拖拉同一段到不同 target。store 內 manualAssignments[paragraph_idx] 是 array。
data-testid="ssp-docx-import-dialog" — 整個 dialogdata-testid="step-upload-framework-dropdown" — Step 0 framework dropdowndata-testid="step-upload-file-input" — FileUpload 元件data-testid="matched-control-{control_id}" — 每個 matched control rowdata-testid="unmatched-paragraph-{idx}" — 每個 unmatched carddata-testid="drop-zone-{control_id}-implementation" / -objective-{key} — drop zonesdata-testid="conflict-modal" — conflict 視窗data-testid="batch-{action}" — 批次按鈕data-testid="confirm-import-btn" — 確認匯入按鈕E2E(compliance-manager-test/)依此 testid 寫 Cucumber step。
| 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 |
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 已具備| 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)。