# 合規框架 PDF 匯入兩階段化 — Frontend Spec

> **文件版本**：1.1
> **建立日期**：2026-05-03
> **最後更新**：2026-05-04（合併 v2.1 迭代）
> **對應 SD**：[design.md](./design.md)
> **對應 SA**：[api-spec.md](./api-spec.md)

---

## 0. 迭代紀錄

| 日期 | 版本 | 變更摘要 | 對應 FE Changelog |
|------|------|---------|------------------|
| 2026-05-03 | 1.0 | 初版 — 兩動態路由 + Step 1/2 元件 + Service + Composable | `2026-05-03-feat-framework-pdf-import-v2.md` |
| 2026-05-04 | 1.1 | (a) Step 1 簡化只剩 parser + 上傳；metadata 三欄移到 Step 2 基本資料 Tab；(b) 「重新上傳 PDF」入口走 resume 路由 + `target_version_uid`；(c) 合規框架版本即時編輯頁（路由 `compliance-framework-version-edit` + `FrameworkVersionEditView` + `FrameworkVersionEditService`）；(d) 共享元件 `components/article/FrameworkArticlePane`、`FrameworkArticleContent`（匯入 Step 2 與編輯頁共用）|  `compliance-framework/20260504_2300_framework-version-edit-mode.md` / `compliance-framework/20260504_2200_batch-delete-loading.md` |

---

## 1. 路由配置

```ts
// src/config/router/index.js (摘錄)
{
  path: '/compliance-framework/import-version',
  name: 'compliance-framework-import-version',
  component: () => import('@/views/compliance-framework/FrameworkImportPage.vue'),
  meta: { /* 沿用 compliance-framework 模組權限 */ },
},
{
  path: '/compliance-framework/import-version/:parseUid',
  name: 'compliance-framework-import-version-resume',
  component: () => import('@/views/compliance-framework/FrameworkImportPage.vue'),
  props: true,
},
{
  // v1.1 新增 — 合規框架版本即時編輯
  path: '/compliance-framework/framework-version/:uid/edit',
  name: 'compliance-framework-version-edit',
  component: () => import('@/views/compliance-framework/FrameworkVersionEditView.vue'),
  props: true,
}
```

> `FrameworkImportPage` 同元件處理「全新上傳」（無 parseUid）/「接手草稿」（有 parseUid）/「重新上傳覆蓋」（resume + parse_job 帶 target_version_uid）三種情境，內部 `onMounted` 依 `route.params.parseUid` + parse_job 內容決定起始 step。

## 2. 元件樹

```
FrameworkImportPage.vue                      (page，含 step 狀態機)
├── DraftBanner.vue                          (Q5C 頁首 banner，列當前 tenant 草稿)
├── Step 1: FrameworkImportUploadStep.vue          (v1.1 簡化)
│   ├── PDFFileDropzone (拖曳/點擊上傳)
│   ├── Dropdown parser_type
│   └── 按鈕「上傳並解析文件」 → POST /parse (僅 parser + file) → router.push <parseUid>
│
│   注：v1.0 在 Step 1 收的 framework_uid / version / release_date / publish_status / parent_version
│        皆已搬到 Step 2「基本資料」Tab；framework_uid 由 caller URL 帶入無需再選。
│
├── Step 2: FrameworkImportPreviewStep.vue   (status=awaiting_review)
│   ├── Splitter
│   │   ├── 左：PDFPreviewIframe (file blob URL 或 file_uid)
│   │   └── 右：TabView
│   │       ├── Tab 0「基本資料」   target_version / target_release_date / target_publish_status (v1.1 移入)
│   │       │                       覆蓋模式下隱藏，只顯示「重新上傳到 vX.Y」badge
│   │       ├── Tab 1「Groups」     GroupTreeEditor.vue
│   │       ├── Tab 2「Controls」   ControlListEditor.vue
│   │       └── Tab 3「AOs」        AssessmentListEditor.vue
│   └── Footer：「捨棄草稿」 + 「確認匯入」（confirm 時把基本資料 Tab 三欄當 metadata 一起送）
└── ErrorPanel.vue                           (status=failed 時呈現)
    └── 顯示 error_code / error_message + 「重新上傳」按鈕（清 parseUid → 退回 Step 1）
```

### 2.1 各 editor 元件職責

**GroupTreeEditor.vue**：
- 用 PrimeVue Tree（draggable）顯示 group 階層（`parent_group_uid` 決定樹）
- 點 node 開 inline form 編 `name` / `description` / `order_no`
- 拖曳重組階層（更新 `parent_group_uid`）
- 每 row 有 ✕ 按鈕：標 `delete` decision；視覺上灰掉並打叉，確認匯入時才實際移除

**ControlListEditor.vue**：
- DataTable + filter by group（dropdown 切換）
- 點 row 展開（PrimeVue expansion）：編 `control_id` / `control_title` / `description` / `guidance` / `order_no`
- `group_uid` 用 dropdown 重綁
- ✕ 標 delete

**AssessmentListEditor.vue**：
- DataTable + filter by control
- 點 row 編 `name` / `description` / `order_no` / `control_uid`
- ✕ 標 delete

**共用 UX 細節**：
- 編輯狀態用「未儲存」徽章標示；切 tab 不會丟失
- `beforeunload` + Vue Router guard：未 confirm 離開頁面跳 confirm dialog（仿 SspDocxImportPage）

## 3. Service 層

新增 `src/service/FrameworkImportService.js`（仿 `SspDocxImportService.js`）：

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

class FrameworkImportService extends BaseService {
  listJobs(params = {}) {
    return this.get(API.OSCAL_FRAMEWORK_PARSE_JOBS, params)
  }
  parse(formData) {
    return this.postFormData(`${API.OSCAL_FRAMEWORK_PARSE_JOBS}/parse`, formData)
  }
  getDetail(parseUid) {
    return this.get(`${API.OSCAL_FRAMEWORK_PARSE_JOBS}/${parseUid}`)
  }
  confirm(parseUid, payload) {
    return this.post(`${API.OSCAL_FRAMEWORK_PARSE_JOBS}/${parseUid}/confirm`, payload)
  }
  discard(parseUid) {
    return this.delete(`${API.OSCAL_FRAMEWORK_PARSE_JOBS}/${parseUid}`)
  }
}

export default new FrameworkImportService()
```

新增 API 常數：

```js
// src/config/api/api.js
OSCAL_FRAMEWORK_PARSE_JOBS: getUrl('/oscal-framework-parse-jobs'),
```

## 4. 狀態管理 / Composable

`src/composables/useFrameworkImportDraft.js`（仿 `useSspDocxDraft`）：

```js
export function useFrameworkImportDraft(parseUid, userUid) {
  const STORAGE_KEY = `framework-import-draft:${parseUid}:${userUid}`

  const draft = ref({
    decisions: [],          // [{type, uid, action: 'delete'}]
    overrides: {            // 覆寫條目，以 uid 為 key
      groups: {},
      controls: {},
      assessments: {},
    },
  })

  function load() { /* localStorage */ }
  function save() { /* localStorage debounced */ }
  function clear() { /* localStorage */ }

  // edits API
  function markDeleted(type, uid) { ... }
  function unmarkDeleted(type, uid) { ... }
  function patch(type, uid, fields) { ... }     // override 欄位合併

  // 對外 export 給 component 收集編輯狀態的 helper
  function buildConfirmPayload() {
    return {
      decisions: draft.value.decisions,
      overrides: {
        groups: Object.values(draft.value.overrides.groups),
        controls: Object.values(draft.value.overrides.controls),
        assessments: Object.values(draft.value.overrides.assessments),
      }
    }
  }

  return { draft, load, save, clear, markDeleted, unmarkDeleted, patch, buildConfirmPayload }
}
```

> Q6 last-write-wins → composable 不需跟 BE sync，純 LocalStorage。
> Key 含 `userUid`：同一瀏覽器多帳號切換不會互相污染。

## 5. 入口替換

`src/views/compliance-framework/ComplianceFrameworkVersionManage.vue`：

| 現況 | 改動 |
|------|------|
| `onImportVersionClick` 開 `importVersionDialog` | `router.push({ name: 'compliance-framework-import-version' })` |
| `importVersionDialog` template / `createDocByImportFile` / `updateDocByImportFile` / 相關 ref | **整段移除** |
| 「匯入版本」按鈕 | 留著但 click handler 改 router.push |

確認 `editVersionDialog`（編輯既有版本的 metadata）等其他 dialog 不受影響。

## 6. i18n

新增 `src/config/locales/i18n/zh-tw/compliance-framework-version-import.json` 與 `en/compliance-framework-version-import.json`：

```json
{
  "compliance_framework_version_import": {
    "title": "匯入合規框架版本",
    "draft_banner": {
      "summary": "目前有 {total} 筆未完成草稿（其中 {mine} 筆是您的）",
      "view": "查看草稿",
      "open": "繼續編輯"
    },
    "step1": {
      "title": "上傳 PDF",
      "framework": "合規框架",
      "version": "版本",
      "release_date": "發行日期",
      "publish_status": "發佈狀態",
      "parser_type": "PDF 解析器",
      "parent_version": "父版本（建立子版本時填寫）",
      "submit": "開始解析"
    },
    "step2": {
      "title": "預覽編輯",
      "tab_groups": "群組",
      "tab_controls": "控制項",
      "tab_assessments": "評估目標",
      "discard": "捨棄草稿",
      "confirm": "確認匯入",
      "deleted_badge": "已標記刪除"
    },
    "error": {
      "parse_failed": "解析失敗",
      "retry_upload": "重新上傳",
      "empty_result": "解析結果為空，無控制項可匯入",
      "version_exists": "此版本已存在",
      "already_confirmed": "草稿已被 {user} 確認匯入"
    },
    "leave_confirm": "尚未儲存的編輯將會遺失，確定離開？"
  }
}
```

## 7. PDF 預覽

- 上傳的 PDF 轉成 `Blob` URL（`URL.createObjectURL`），LocalStorage **不存** binary
- 中途換 tab / 重整：blob URL 失效，改顯示「已關閉預覽，可重新上傳同份檔以恢復預覽」提示，不影響繼續編輯（編輯來源是 BE `parsed_result` + LocalStorage draft）

## 8. 錯誤回饋路徑

| 場景 | UI |
|------|-----|
| Parse 失敗（status=failed） | `ErrorPanel` 顯示 error_code 對照表訊息 + 「重新上傳」按鈕 |
| Confirm 拿到 409 already_confirmed | toast 顯示「已被 {created_user_name} 於 {timestamp} 確認匯入」並 router.push 列表頁 |
| Confirm 拿到 400 empty | toast 顯示「至少保留 1 個控制項才能匯入」 |
| Confirm 拿到 400 invalid_reference | toast 顯示「有控制項或 AO 引用了已刪除的群組，請檢查」 |
| 網路錯誤 | toast「網路錯誤，請稍後再試」+ retry button |

## 9. 不在 FE Spec 範圍

- Vue Router 權限攔截規則（RBAC SOP 另行處理）
- Component 內部 reactive 狀態管理細節（由 FE 開發時 self-evident）
- PrimeVue Tree drag-drop 細節 API（依官方文件）

---

## 10. 合規框架版本即時編輯頁（v1.1 新增）

### 10.1 路由與入口

- **路由**：`/compliance-framework/framework-version/:uid/edit`（name: `compliance-framework-version-edit`）
- **入口**：`ComplianceFrameworkVersionManage.vue` 的 row action「編輯版本」
- **內部 props**：`uid`（version uid）；query `from_uid` 可帶 framework uid 用於返回導航

### 10.2 元件樹

```
FrameworkVersionEditView.vue
├── Card 標題列（版本號 + publish_status badge + 「重新上傳 PDF」按鈕 + 返回）
├── Splitter
│   ├── 左：PDF iframe (優先 file_uid 走 PDF_FILE_PREVIEW；fallback OSCAL_FRAMEWORK_VERSION_DOWNLOAD)
│   └── 右：FrameworkArticlePane (共享元件)
│         └── FrameworkArticleContent (groups / controls / assessments tree，inline edit + cascade delete 視覺)
└── Confirm dialog（重新上傳 PDF 入口 → router.push 匯入 resume 路由 + parse_job target_version_uid）
```

### 10.3 寫入策略

每個 patch / delete event 即時 PUT/DELETE BE，**無 staging**（對齊 module_frame default-edit 風格）。

```js
// FrameworkArticlePane 派發事件 → FrameworkVersionEditView 收
@patch="(payload) => FrameworkVersionEditService.updateGroup(uid, payload)"
@delete="(uid) => FrameworkVersionEditService.deleteControl(uid)"
```

### 10.4 雙軌守門（FE 端對齊 BE）

| 條件 | UI 行為 |
|------|--------|
| `publish_status != 'draft'` | `readOnly=true` — 整頁文字輸入 disabled、按鈕灰；header 顯示「published / deprecated 不可編輯」 |
| `has_references=true`（draft 但有引用）| `readOnly=false` 但 `allowDelete=false` — 文字可改、刪除按鈕隱藏；header 顯示「已被合規資源庫引用」 |
| 兩者皆 false | 完整可改可刪 |

> 進頁時 `getCatalogTree` 一次取回 `publish_status` + `has_references`；FE 計算 `readOnly` / `allowDelete` 兩個 computed 控制 UI。

### 10.5 共享元件 `components/compliance-framework/article/`

- `FrameworkArticlePane.vue`：左 PDF / 右 article 配置 wrapper（編輯頁 + 匯入頁 Step 2 通用）
- `FrameworkArticleContent.vue`：實際顯示 groups → controls → assessments 階層 tree + inline edit + 標 delete 視覺

### 10.6 Service

`src/service/FrameworkVersionEditService.js`：

```js
class FrameworkVersionEditService extends BaseService {
  getCatalogTree(versionUid) { return this.get(`${API.OSCAL_FRAMEWORK_VERSION_CATALOG_TREE}/${versionUid}/catalog-tree`) }
  updateGroup(uid, fields) { return this.put(`${API.OSCAL_CATALOG_GROUP}/${uid}`, fields) }
  deleteGroup(uid) { return this.delete(`${API.OSCAL_CATALOG_GROUP}/${uid}`) }
  updateControl(uid, fields) { return this.put(`${API.OSCAL_CATALOG_CONTROL}/${uid}`, fields) }
  deleteControl(uid) { return this.delete(`${API.OSCAL_CATALOG_CONTROL}/${uid}`) }
  updateAssessment(uid, fields) { return this.put(`${API.OSCAL_CATALOG_CONTROL_ASSESSMENT}/${uid}`, fields) }
  deleteAssessment(uid) { return this.delete(`${API.OSCAL_CATALOG_CONTROL_ASSESSMENT}/${uid}`) }
}
```

新增 API 常數：
```js
OSCAL_FRAMEWORK_VERSION_CATALOG_TREE: getUrl('/oscal-framework-version'),  // GET /<uid>/catalog-tree
OSCAL_CATALOG_GROUP: getUrl('/oscal-catalog-group'),
OSCAL_CATALOG_CONTROL: getUrl('/oscal-catalog-control'),
OSCAL_CATALOG_CONTROL_ASSESSMENT: getUrl('/oscal-catalog-control-assessment'),
```

### 10.7 i18n menu 新 key

- `compliance-framework-import-version` = "匯入框架版本"
- `compliance-framework-import-version-resume` = "繼續編輯草稿"
- `compliance-framework-version-edit` = "編輯框架版本"
