This commit is contained in:
2026-07-20 14:46:07 +07:00
parent 476c41cf08
commit 4a159cad71
10 changed files with 2224 additions and 3 deletions

2
.gitignore vendored
View File

@@ -10,3 +10,5 @@ web-client/dist/
web-server/uploads/ web-server/uploads/
docs/* docs/*
output/
tmp/

770
design-language.md Normal file
View File

@@ -0,0 +1,770 @@
---
type: foundation-layer
layer: design-language
status: draft
version: 0.4
phase: icon-subphase
last-bump: 2026-06-01
source-priority:
- foundation-4-layer
- research-method-d
- em-propose
tags:
- brand
- design-language
- foundation
---
> [!WARNING] **[DRAFT — chưa ratify, KHÔNG adopt cho production deliverable]**
> File này đang xây (Phase 1-3 xong, còn Phase 4-5). Status sẽ chuyển `ratified` khi Phase 5 close + audit cycle pass. Trong khi đang xây: tham khảo OK, KHÔNG dùng làm authority binding cho output Samsung/Nichirin/ODM.
# Design Language Phenikaa-X v0.4
> [!NOTE] **Errata mới nhất: v0.4.4 (2026-06-10)** — xem [Lịch sử phiên bản](#lịch-sử-phiên-bản). Nhãn H1 + frontmatter giữ `v0.4` theo chính sách errata-no-bump; patch level thực ở Lịch sử.
Hệ quy chiếu thiết kế universal cross-surface (Office/Web/HMI/Physical). Layer 1 = nguyên tắc + token + bố cục cơ bản, vendor-agnostic. Layer 2 (Implementation per platform) defer team dev tự build.
## 1. Nguyên tắc nền tảng
> [!NOTE] Thuật ngữ đoạn dưới: **token** = đơn vị thiết kế nguyên tử dùng lại được (màu, cỡ chữ, khoảng cách...) định nghĩa một lần, áp dụng mọi nơi · **thứ bậc thị giác** = cách bố cục dẫn mắt người đọc theo mức độ quan trọng · **WCAG 2.2 AA** = chuẩn quốc tế về khả năng tiếp cận nội dung số.
Năm nguyên tắc định hình mọi quyết định thiết kế Phenikaa-X trên mọi bề mặt (tài liệu, web, giao diện máy, biển hiệu). Ba nguyên tắc đầu truy nguyên giá trị cốt lõi của tổ chức — giữ bản sắc. Hai nguyên tắc sau bảo đảm phạm vi dùng chung của Layer 1. Mỗi nguyên tắc gồm: định nghĩa + cách áp dụng + nguồn.
### 1.1 Minh bạch
Thiết kế nói thật. Thông tin quan trọng hiện rõ, không trang trí gây nhiễu, thứ bậc thị giác phản ánh đúng mức độ quan trọng của nội dung.
**Áp dụng:** Dashboard cycle-time → số liệu chính nổi bật, bỏ trang trí thừa trong biểu đồ. Báo cáo FAT → kết quả đạt/không-đạt rõ, không màu mè che dữ liệu. Slide → một thông điệp chính mỗi trang.
**Nguồn:** `identity-from-raw.md` §11.2 (Trung thực và minh bạch)
### 1.2 Rõ ràng
Mỗi yếu tố thiết kế phải có mục đích. Ưu tiên đọc-hiểu nhanh, súc tích, đúng trọng tâm — không thêm yếu tố không phục vụ thông điệp.
**Áp dụng:** Proposal → tiêu đề phân cấp rõ, đoạn ngắn. Bảng số liệu → chỉ giữ cột thật cần. Loại bỏ chi tiết trang trí không mang nghĩa.
**Nguồn:** `applied-from-raw.md` §1 (giọng viết: rõ ràng + súc tích + đúng trọng tâm) + `[fact]` Fluent 2 "Coherent" + Carbon clarity — [Fluent 2 design principles](https://fluent2.microsoft.design/design-principles)
### 1.3 Trách nhiệm
Thiết kế nhất quán, chuẩn mực, đáng tin qua mọi sản phẩm. Cùng một token (màu, font, khoảng cách) áp dụng nhất quán — người nhận nhận ra "đây là Phenikaa-X" trước cả khi thấy logo.
**Áp dụng:** Mọi output dùng chung bảng màu + thang chữ + thang khoảng cách. Không tùy biến tự phát mỗi tài liệu.
**Nguồn:** `identity-from-raw.md` §11.3 (Trách nhiệm cam kết đến cùng) + §13 (6 Hexagon văn hóa — Kỷ luật hexagon #2) + `[fact]` Carbon "builds consistency" — [Carbon — what is Carbon](https://carbondesignsystem.com/all-about-carbon/what-is-carbon/)
### 1.4 Có thể truy cập
Thiết kế cho mọi người dùng, không loại trừ ai. Tuân chuẩn WCAG 2.2 AA: đủ tương phản màu, cỡ chữ đọc được, không chỉ dựa vào màu để truyền thông tin.
**Áp dụng:** Cặp màu chữ trên nền đạt tỷ lệ tương phản ≥ 4.5:1 (chữ thường). Trạng thái phân biệt thêm bằng icon hoặc chữ, không chỉ bằng màu. Chữ thân bài không nhỏ hơn ngưỡng đọc được.
**Nguồn:** `identity-from-raw.md` §11.7 (Tôn trọng và trao quyền) + `[fact]` Carbon "Carbon is inclusive" — [Carbon — what is Carbon](https://carbondesignsystem.com/all-about-carbon/what-is-carbon/)
### 1.5 Đồng bộ đa nền tảng
Một hệ token chung phục vụ mọi bề mặt: tài liệu Office, web, giao diện máy (HMI), biển hiệu vật lý. Layer 1 định nghĩa token độc lập nền tảng; Layer 2 (team dev) ánh xạ sang định dạng từng nền tảng.
**Áp dụng:** Màu, font, khoảng cách định nghĩa một lần ở Layer 1 → xuất sang `.thmx` (Office), biến CSS (web), theme giao diện máy. Không định nghĩa lại token riêng cho mỗi nền tảng.
**Nguồn:** `[fact]` Fluent 2 token "common language" đồng bộ đa nền tảng — [Fluent 2 design tokens](https://fluent2.microsoft.design/design-tokens) + phạm vi Layer 1 (xem `spec-design-language-stream.md`)
## 2. Token
### 2.1 Color
> [!NOTE] Thuật ngữ: **token** = đơn vị màu định nghĩa một lần dùng mọi nơi · **scale/thang** = các bậc đậm-nhạt của một màu · **semantic alias** = tên theo công dụng (vd `text.primary`) trỏ tới một bậc màu cụ thể · **tỷ lệ tương phản** = chỉ số WCAG đo độ đọc được của chữ trên nền.
Hệ màu canonical Layer 1 — nguồn duy nhất cho mọi bề mặt. Mô hình 3 tầng (kế thừa concept tốt từ prior art cycle-time, sửa màu + chuẩn scope):
| Tầng | Trách nhiệm | Sống ở đâu |
|---|---|---|
| **L1 — token universal** | Brand scale + neutral + semantic + theme alias (file này) | `design-language.md` §2.1 |
| **L2 — platform** | Ánh xạ token sang `.thmx`/CSS/HMI theme + bộ data-viz dashboard | Team dev / `40-knowledge/cycle-time-dashboard/` |
| **L3 — project** | Override white-label ODM/khách | `30-projects/<proj>/.../dashboard-config` |
Thang xanh + cam **neo trên màu chính thức Tập đoàn** (Trang 18-19 guideline). Neutral + semantic **thiết kế mới** (palette Tập đoàn không có xám/semantic) — hài hoà brand, đạt WCAG.
#### 2.1.1 Brand color
**Thang Xanh** (neo Deep Blue chủ đạo + Dark Blue Phenikaa-X):
| Token | Hex | Nguồn | Vai trò |
|---|---|---|---|
| `blue-50` | `#EAEDF5` | tint mới | Nền hover/selected (light) |
| `blue-100` | `#C5CCE0` | tint mới | Nền nhấn nhẹ |
| `blue-200` | `#8C9AC4` | tint mới | Link/accent trên nền tối |
| `blue-300` | `#6E7CA8` | tint mới | UI lớn trên nền tối |
| `blue-500` | `#223771` | **official Deep Blue** | **Chủ đạo** — heading, link, nút chính |
| `blue-700` | `#1A2A57` | shade mới | Nhấn đậm |
| `blue-900` | `#1e2243` | **official Dark Blue** | Đậm nhất — footer, nền dark mode |
> Màu xanh official ngả cyan của Tập đoàn (`#03428E`, `#004E96`, `#00599D` — Trang 18) giữ làm **xanh accent đặc biệt** (sự kiện, đồ hoạ môi trường). Không đưa vào thang chính vì hue lệch sang cyan.
**Thang Cam** (4 bậc official + tint/shade):
| Token | Hex | Nguồn | Vai trò |
|---|---|---|---|
| `orange-100` | `#FCD9C4` | tint mới | Nền cảnh báo nhẹ |
| `orange-200` | `#F7941E` | **official** cam nhạt | Accent sáng (nền tối) |
| `orange-300` | `#F58220` | **official** | Accent |
| `orange-400` | `#F37021` | **official** | Accent đậm |
| `orange-500` | `#f26522` | **official Sun Orange** | **Accent chủ đạo** — highlight, CTA |
| `orange-700` | `#C44E14` | shade mới | Nhấn đậm |
#### 2.1.2 Neutral (thiết kế mới — xám ngả xanh hài hoà brand)
| Token | Hex | Vai trò chính |
|---|---|---|
| `neutral-0` | `#FFFFFF` | Nền cơ sở (light) |
| `neutral-50` | `#F7F8FB` | Nền nâng nhẹ |
| `neutral-100` | `#EEEDF6` | = Brilliant White brand tint |
| `neutral-200` | `#DDDFEA` | Nền phân vùng |
| `neutral-300` | `#C5C8D8` | Viền trang trí (divider) |
| `neutral-400` | `#9FA3B8` | Icon phụ, disabled |
| `neutral-450` | `#8C90A8` | **Viền thiết yếu** (ô input — đạt 3:1) |
| `neutral-500` | `#757A91` | Chữ cấp 3 (caption, large) |
| `neutral-550` | `#6A6F88` | Viền thiết yếu dark mode (= `border.strong` dark) |
| `neutral-600` | `#565B70` | Chữ phụ (đạt AA) |
| `neutral-700` | `#3C4054` | Viền thiết yếu (dark mode area) |
| `neutral-800` | `#272A3A` | Nền thẻ (dark) |
| `neutral-900` | `#161824` | Chữ chính (light) · nền cơ sở (dark) |
#### 2.1.3 Semantic (thiết kế mới — căn theo IEC 60204-1 + ISO 3864)
Màu trạng thái tuân quy ước an toàn công nghiệp (xanh lá = an toàn/chạy, đỏ = dừng/cấm, vàng = cảnh báo, xanh dương = thông tin) để không mâu thuẫn HMI/biển báo. Mỗi màu có biến thể light + dark.
| Token | Light | Dark | Ý nghĩa |
|---|---|---|---|
| `success` | `#2E7D32` | `#66BB6A` | An toàn / đạt / chạy |
| `warning` | `#9A5B00` | `#FFB74D` | Cảnh báo / cần để ý |
| `error` | `#C62828` | `#EF9A9A` | Lỗi / dừng / vượt ngưỡng |
| `info` | `#1565C0` | `#90CAF9` | Thông tin / bắt buộc |
> Quy tắc: trạng thái KHÔNG chỉ phân biệt bằng màu — luôn kèm icon/chữ (per WCAG 1.4.1 + color-blindness, research D §5.3).
**Biến thể `error.text-sm`** (v0.2 — chỉ cho chữ/nhãn đỏ cỡ nhỏ, KHÔNG phải status mới):
| Token | Light | Dark | Dùng cho |
|---|---|---|---|
| `error.text-sm` | `#A4262C` | `#F2B8B5` | Chữ/số đỏ cỡ nhỏ (914px) **trên nền sáng** — đỏ trầm chống nhoè viền do tán sắc |
> **Khi nào cần `error.text-sm`:** đỏ no `error` `#C62828` chỉ **nhoè viền** khi HỘI ĐỦ — **cỡ nhỏ (914px) + nét mảnh + bão hoà cao + trên nền sáng/trắng**; nguyên nhân là **tán sắc ngang của mắt (TCA — chromatic aberration)**: bước sóng đỏ lấy nét lệch so với bước sóng khác nên viền nhoè, rõ nhất trên nền trắng `[fact]`. → dùng `error.text-sm` trong đúng điều kiện đó. **KHÔNG cần** đỏ trầm khi thiếu một điều kiện: chữ lớn/đậm · fill/pill/icon/khối đặc (mép nhoè không đáng kể so với diện tích) · **nền tối** — tán sắc gần như **không thấy trên nền đen** `[fact]`, nên dark-mode giữ đỏ sáng `#F2B8B5` (không cần trầm). Đây là biến thể công dụng, KHÔNG đổi nghĩa `error`.
**Biến thể `warning.icon`** (v0.4 — chỉ cho icon/tiêu đề cảnh báo CỠ LỚN, KHÔNG phải status mới):
| Token | Light | Dark | Dùng cho |
|---|---|---|---|
| `warning.icon` | `#CB6119` | `#FFB74D` (= `warning` dark) | Icon + tiêu đề callout cảnh báo cỡ lớn (≥18px / ≥14px đậm) — cam ISO tươi hợp hue an toàn |
> Ngược chiều `error.text-sm`: `warning` `#9A5B00` cố tình **trầm** để chữ/nhãn cỡ nhỏ vẫn đạt AA 4.5:1. Nhưng icon + tiêu đề callout **cỡ lớn** chỉ cần ngưỡng 3:1 (WCAG 1.4.11 / 1.4.3 large) → dùng cam ISO tươi `#CB6119` (3.99:1 / white · 3.76:1 / `neutral-50` — `[fact]` công thức W3C) cho hợp hue cảnh báo ANSI/ISO + đẹp hơn nâu trầm. Nền tối: `warning` dark `#FFB74D` đã đủ sáng, không cần biến thể. Đây là biến thể công dụng, KHÔNG đổi nghĩa `warning`.
**Biến thể `caution.fill`** (v0.4.4 — nền vàng callout mức "Thận trọng" ANSI Y2 / ISO 3864 signal yellow, KHÔNG phải status mới):
| Token | Light | Dark | Dùng cho |
|---|---|---|---|
| `caution.fill` | `#F9A900` | `#FFB74D` (= `warning` dark) | Nền callout "Thận trọng" (mức nhẹ hơn "Cảnh báo") — **buộc chữ đen `neutral-900`** |
> Phân biệt 3 cấp cảnh báo (theo ANSI Z535 / ISO 3864): **đỏ `error`** = Nguy hiểm/Cấm · **cam `warning.icon` `#CB6119`** = Cảnh báo (W2, icon/tiêu đề cỡ lớn) · **vàng `caution.fill` `#F9A900`** = Thận trọng (Y2, nền callout). Vàng `#F9A900` quá sáng → chữ trắng **FAIL** (1.96:1 — `[fact]` công thức W3C), nên **buộc chữ đen `neutral-900`** (8.99:1 AAA). Khoá ở `research-status-colors-office.md` §6 (D202, owner ratify S56). Đây là biến thể công dụng nền, KHÔNG đổi nghĩa `warning`.
#### 2.1.4 Theme alias (light + dark mode)
Tên theo công dụng — đây là lớp mà output thật tham chiếu, KHÔNG hardcode hex.
| Alias | Light → token | Dark → token |
|---|---|---|
| `surface.base` | `neutral-0` | `neutral-900` |
| `surface.raised` | `neutral-50` | `neutral-800` |
| `surface.brand-tint` | `neutral-100` | `blue-900` |
| `text.primary` | `neutral-900` | `neutral-100` |
| `text.secondary` | `neutral-600` | `neutral-400` |
| `text.tertiary` | `neutral-500` | `neutral-500` |
| `border.subtle` | `neutral-300` | `neutral-700` |
| `border.strong` | `neutral-450` | `neutral-550` |
| `brand.primary` | `blue-500` | `blue-200` |
| `brand.accent` | `orange-500` | `orange-300` |
| `link` | `blue-500` | `blue-200` |
#### 2.1.5 AMR body paint + decal (bề mặt vật lý)
| Token | Spec | Khớp brand | Trạng thái |
|---|---|---|---|
| `paint.body.primary` | **RAL 5011 Steel Blue** | ≈ Deep Blue `#223771` | `[inference]` cần mẫu RAL vật lý chốt |
| `paint.body.accent` | **RAL 2008 Bright Red Orange** | ≈ Sun Orange `#f26522` | `[inference]` cần mẫu RAL vật lý chốt |
| `paint.body.dark` | **RAL 5022 Night Blue** | ≈ Dark Blue `#1e2243` | `[inference]` cần mẫu RAL vật lý chốt |
| `paint.body.light` | **RAL 9003 Signal White** | ≈ Brilliant White `#eeedf6` | `[inference]` cần mẫu RAL vật lý chốt |
| `decal.logo` | Pantone Solid Coated | logo decal trên thân AMR | GAP — chưa có Pantone official (defer M2.5) |
> 4 màu sơn khớp đủ 4 màu chính thức Phenikaa-X (Deep Blue + Sun Orange + Dark Blue + Brilliant White, per `visual-from-raw.md` §11).
> Màu nền thân robot (xám máy móc nền — vd RAL 7035) là quyết định product-spec per model, không khoá ở Layer 1.
#### 2.1.6 WCAG 2.2 AA verify (tính bằng `[fact]` công thức relative luminance W3C)
| Cặp | Tỷ lệ | Kết quả |
|---|---|---|
| text.primary / surface.base (light) | 17.6:1 | AAA |
| text.secondary `neutral-600` / white | 6.7:1 | AA |
| brand.primary `blue-500` / white | 11.3:1 | AAA |
| chữ trắng / nền `blue-500` | 11.3:1 | AAA |
| **chữ trắng / nền `orange-500`** | **3.15:1** | FAIL body · **PASS chữ lớn** (≥3:1) — xem quy tắc phân tầng |
| chữ tối `neutral-900` / `orange-500` | 5.6:1 | AA mọi cỡ ✅ |
| border.strong `neutral-450` / white | 3.15:1 | AA non-text |
| success/warning/error/info / white | ≥5.1 / 5.4 / 5.6 / 5.8 | AA |
| text.primary `neutral-100` / surface.base (dark) | 15.2:1 | AAA |
| link `blue-200` / nền tối | 6.3:1 | AA |
| semantic dark variant / nền tối | ≥7.4 | AAA |
| `error.text-sm` `#A4262C` / white | 7.26:1 | AAA |
| `error.text-sm` `#F2B8B5` / nền tối `#1e2243` | 9.01:1 | AAA |
| `warning.icon` `#CB6119` / white | 3.99:1 | AA chữ lớn/icon (≥3:1) · FAIL body |
| `warning.icon` `#CB6119` / `neutral-50` | 3.76:1 | AA chữ lớn/icon (≥3:1) |
| `caution.fill` `#F9A900` / `neutral-900` (chữ đen) | 8.99:1 | AAA |
| `caution.fill` `#F9A900` / white (chữ trắng) | 1.96:1 | FAIL — buộc chữ đen |
> [!IMPORTANT] **Quy tắc dùng chữ trên nền cam (binding — theo bậc cam × cỡ chữ):**
> Tính `[fact]` bằng công thức W3C. Cam càng nhạt, chữ trắng càng fail — KHÔNG áp dụng "chữ lớn trắng OK" cho mọi bậc cam:
> | Nền | Chữ trắng | Chữ tối `neutral-900` | Khuyến nghị |
> |---|---|---|---|
> | `orange-200/300/400` (nhạt) | FAIL (≤2.94:1, kể cả chữ lớn) | AA mọi cỡ (6.07.7) | **Luôn chữ tối** |
> | `orange-500` Sun Orange | chữ lớn OK (3.15:1), body FAIL | AA mọi cỡ (5.6) | Logo + chữ lớn/CTA = trắng; body = tối |
> | `orange-700` (đậm) | AA mọi cỡ (4.72) | chỉ chữ lớn (3.74) | Trắng OK mọi cỡ |
>
> **Hệ quả thực tế:** nút cam **light mode** (`brand.accent`=orange-500) CTA lớn → trắng OK, nút nhỏ → tối. Nút cam **dark mode** (`brand.accent`=orange-300, nhạt) → **luôn chữ tối**. Logo/wordmark: trắng luôn được (logotype miễn trừ WCAG `[fact]`).
>
> **Quy tắc khác:**
> 1. **30% rule** (truyền thông): ấn phẩm marketing ≥30% diện tích màu chủ đạo (Deep Blue/Sun Orange) — per `research-method-d-standard.md` §5.1 (quy tắc 60-30-10).
> 2. Trạng thái kèm icon/chữ, không chỉ màu (WCAG 1.4.1).
> [!NOTE] **Hai thước đo tương phản (dual-metric):** Bảng trên tính bằng **WCAG 2.x** (chuẩn pháp lý hiện hành). WCAG 2.x có lỗi đã ghi nhận với màu cam ("orange problem") — chấm chữ trắng/cam ~3:1 fail dù thực tế đọc rõ. **APCA** (thuật toán cảm nhận thị giác, sẽ vào WCAG 3) phản ánh chính xác hơn. Quy tắc phân tầng cam ở trên dựa trên cả hai. Test harness `50-plans/phenikaa-x-design-system/color-test-harness.html` tính song song WCAG + APCA + mô phỏng mù màu.
**Nguồn:** `visual-from-raw.md` §11 (4 màu chính + parent 8 tone) + parent guideline Trang 18-19 (crop `color-palette-aux.jpg` 400 DPI verified) + `research-method-d-standard.md` §5.2 (WCAG 2.2) + §9.1 (RAL/Pantone AMR) + `[fact]` [W3C WCAG 2.2](https://www.w3.org/TR/WCAG22/) · [WCAG 1.4.3 logotype exemption](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) · [APCA contrast](https://git.apcacontrast.com/documentation/APCAeasyIntro.html) · [Carbon color tokens](https://carbondesignsystem.com/elements/color/tokens/) · RAL `[inference]` [RALcolorchart](https://www.ralcolorchart.com/ral-classic)
#### 2.1.7 Data-viz — 4 họ màu theo ý đồ
> [!NOTE] Thuật ngữ: **categorical** = màu phân loại nhóm rời rạc · **sequential** = thang 1 màu cho độ lớn · **diverging** = 2 màu quanh mốc giữa · **ΔE** = chỉ số khoảng cách 2 màu (CIEDE2000; >15 phân biệt rõ, 1015 ổn, <10 dễ lẫn).
Màu data-viz chọn theo **ý đồ truyền đạt**, KHÔNG phải 1 bộ cố định (`[fact]` Few "Show Me the Numbers" + Knaflic "Storytelling with Data": màu = ý đồ, phần lớn biểu đồ chỉ cần 12 màu). Bốn họ:
| Họ | Dùng khi | Alias |
|---|---|---|
| **Categorical** | Phân loại series rời rạc (≤6) | `dataviz.cat-1…6` |
| **Sequential** | Độ lớn 1 chiều (mật độ, throughput) | `dataviz.seq-50…900` |
| **Diverging** | Lệch quanh mốc (sai số ±, trên/dưới target) | `dataviz.diverge-neg/mid/pos` |
| **Status** | Ngưỡng an toàn | đã khoá §2.1.3 |
> Categorical **cố tình chừa hue xanh-lá/đỏ/vàng** — để dành cho status. Đây là quyết định đo được (khoảng cách tới màu status ΔE 20.1, không đụng nghĩa "đạt/cảnh báo/lỗi"), không phải sở thích.
>
> **Khi nào dùng họ nào + luật status-override** (lúc data vượt ngưỡng an toàn → đổi sang màu status): xem `_foundation/patterns/pattern-dashboard.md` §2.
##### 2.1.7.1 Categorical
Bộ **4 màu** (`cat-1…4`) = dùng nhiều nhất (~90% biểu đồ). Thêm `cat-5, cat-6` chỉ khi buộc 56 series. Trên 6 series → đổi **small-multiples** (chia nhiều biểu đồ nhỏ), KHÔNG thêm màu thứ 7 (`[fact]` Cleveland-McGill: màu là kênh tri giác kém chính xác nhất).
| Token | Light | Dark |
|---|---|---|
| `dataviz.cat-1` | `#223771` | `#7AA2F7` |
| `dataviz.cat-2` | `#f26522` | `#F7941E` |
| `dataviz.cat-3` | `#8E6CF0` | `#B79CF5` |
| `dataviz.cat-4` | `#22A0A8` | `#56C7D6` |
| `dataviz.cat-5` *(chỉ khi cần 56)* | `#3E89C0` | `#4FB0DD` |
| `dataviz.cat-6` *(chỉ khi cần 56)* | `#B05CA0` | `#E07BB0` |
##### 2.1.7.2 Sequential
Thang đơn sắc (single-hue) xanh thương hiệu, **độ sáng đều cảm nhận** — dựng bằng OKLab (cố định hue 266° của Deep Blue + chroma ~0.10, chia độ sáng đều), **neo cứng 2 đầu** vào màu chính thức Tập đoàn. Thay thang mượn v0.2 (trả "nợ" perceptual-uniform).
| Token | Hex | L (OKLab) | Vai trò |
|---|---|---|---|
| `dataviz.seq-50` | `#ECF2FF` | 0.960 | mật độ thấp nhất (sáng nhất) |
| `dataviz.seq-100` | `#C3D5FF` | 0.873 | |
| `dataviz.seq-200` | `#9CB8FA` | 0.786 | |
| `dataviz.seq-300` | `#819DDD` | 0.700 | |
| `dataviz.seq-400` | `#6882C1` | 0.613 | |
| `dataviz.seq-500` | `#5068A5` | 0.526 | |
| `dataviz.seq-600` | `#394F8A` | 0.440 | |
| `dataviz.seq-700` | `#23376F` | 0.353 | ≈ **Deep Blue official** `#223771` |
| `dataviz.seq-900` | `#1e2243` | 0.267 | mật độ cao nhất = **Dark Blue official** (neo cứng) |
> **Bước độ sáng** mọi bậc ~**0.087** (đều — không nhảy vọt, không dồn cục; đo bằng `50-plans/phenikaa-x-design-system/` script OKLab). Số hiệu token là *nhãn quy ước* (bỏ `800` để giữ `seq-900` neo Dark Blue ở đáy) — bước *độ sáng* vẫn đều bất kể nhãn.
>
> **Dark mode:** đảo nhạt↔đậm theo nền — `seq-900` thành đầu nhạt, `seq-50` thành đầu đậm (giữ cơ chế v0.2). `[inference]` verify khi render thật.
##### 2.1.7.3 Diverging
Hai màu brand quanh mốc trung tính (không đỏ/xanh-lá → không đụng status). Cân độ sáng 2 cánh để không tạo bất đối xứng giả.
| Token | Light | Dark |
|---|---|---|
| `dataviz.diverge-neg` (cánh âm) | `#5B9BD0` | `#6FAEDD` |
| `dataviz.diverge-mid` (mốc 0) | `#EEEDF6` | `#2C3158` |
| `dataviz.diverge-pos` (cánh dương) | `#F58220` | `#F7941E` |
> Giá trị `[inference]` — 2 cánh cân độ sáng ~L*6270; owner verify khi xem render thật.
> [!NOTE] **Bằng chứng + rủi ro tồn dư** (đo bằng `50-plans/phenikaa-x-design-system/palette_eval.py`): Categorical sáng ΔE phân biệt 28.8 (4 màu) / 17.8 (6 màu), khoảng cách status 20.1 ✅. Categorical tối 15.0 (4 màu) / 11.9 (6 màu) — **khoảng cách status 9.6 ⚠️** (`cat-2` cam gần warning-dark): dark mode ưu tiên 4 màu + status luôn kèm icon để không lẫn.
**Nguồn:** `spec-pattern-dashboard.md` (quyết định D135D138 + bằng chứng) + `spec-phase-3.md` (sequential brand-anchored D147) + `research-method-d-standard.md` §7.3 (Cleveland-McGill `[fact]`) + `[fact]` Few "Show Me the Numbers" + Knaflic "Storytelling with Data" + OKLab (Ottosson 2020) + [ColorBrewer](https://colorbrewer2.org) + [viridis](https://bids.github.io/colormap/) + `palette_eval.py` / `seq_anchor.py` (số đo CIEDE2000 + OKLab L + WCAG W3C).
### 2.2 Typography
> [!NOTE] Thuật ngữ: **type scale** = bộ cỡ chữ chuẩn (tiêu đề → chữ thường → chú thích) · **vai trò (role)** = tên theo công dụng (Body, Heading...) thay vì cỡ cụ thể · **độ cao dòng (line-height)** = khoảng cách giữa 2 dòng · **px** = đơn vị màn hình, **pt** = đơn vị Word/in (1pt ≈ 1.33px).
#### 2.2.1 Font family
- **Body + Heading:** **Montserrat** — Phenikaa-X UNIQUE retain (owner ratify S12, sub-brand style), per `visual-from-raw.md` §12. Montserrat có bộ ký tự tiếng Việt đầy đủ (Google Fonts Vietnamese subset) `[fact]`.
- **Độ đậm dùng:** Regular 400 · Medium 500 · SemiBold 600 · Bold 700.
- **Fallback:** `system-ui, -apple-system, "Segoe UI", sans-serif` (khi Montserrat chưa load).
- Lưu ý: font trong **logo** ≠ font body (logo có style riêng, per `visual-from-raw.md` §2 Logo).
- **UTM Avo** là font của tập đoàn Phenikaa Group — chỉ là tham chiếu, **KHÔNG dùng** cho Phenikaa-X (owner ratify S12, sub-brand giữ style riêng, per `visual-from-raw.md` §16.1 #2).
> [!NOTE] **Ngoại lệ phạm vi — văn bản hành chính gửi cơ quan nhà nước**
> Montserrat áp dụng cho **mọi output do Phenikaa-X kiểm soát** (proposal, slide, brochure, HMI, web, tài liệu kỹ thuật). **Ngoại lệ:** văn bản hành chính gửi cơ quan nhà nước (công văn, tờ trình, hồ sơ thầu dự án nhà nước) → dùng **Times New Roman cỡ 1314**, mã Unicode TCVN 6909:2001, theo Nghị định 30/2020/NĐ-CP `[fact]`.
>
> Đây là **format pháp lý bên ngoài, brand không override** — cùng nguyên tắc với màu an toàn IEC 60204-1 / ISO 3864 (đỏvàng dừng khẩn không được đổi sang xanh brand).
>
> Phenikaa-X là công ty cổ phần tư nhân → **không thuộc đối tượng bắt buộc** của Nghị định 30 (đối tượng = cơ quan/tổ chức nhà nước + doanh nghiệp nhà nước) `[fact]`. Nhưng **nên tuân theo** khi tài liệu vào quy trình văn thư nhà nước, để bên nhận chấp nhận `[inference]`.
#### 2.2.2 Type scale (Major Third 1.25 — neo Body theo thực tế Word 11pt)
Thang theo tỷ lệ 1.25 (Bringhurst `[fact]`). Body neo **11pt** (cỡ Word A4 đang dùng) → Web 16px. 2 cột đơn vị cho 2 môi trường:
| Vai trò | Web/HMI (px) | Word/In (pt) | Độ đậm | Line-height | Dùng cho |
|---|---|---|---|---|---|
| Display | 39 | 28 | Bold 700 | 1.1× | Bìa, tiêu đề slide lớn |
| Heading 1 | 31 | 22 | Bold 700 | 1.15× | Tiêu đề chính tài liệu |
| Heading 2 | 25 | 18 | SemiBold 600 | 1.2× | Tiêu đề mục |
| Heading 3 | 20 | 14 | SemiBold 600 | 1.3× | Tiêu đề mục con |
| Heading 4 | 18 | 12 | Bold 700 | 1.3× | Tiêu đề phụ (hơi lớn hơn body) |
| Heading 5 | 16 | 11 | SemiBold 600 | 1.4× | Tiêu đề ngang cỡ body, nhạt hơn H4 (weight phân biệt) |
| Heading 6 | 16 | 11 | SemiBold 600 · CHỮ HOA | 1.4× | Tiêu đề nhỏ nhất (phân biệt bằng chữ hoa) |
| **Body** | 16 | **11** | Regular 400 | **1.5×** | Chữ thường (đoạn văn) |
| Label | 13 | 9 | Medium 500 | 1.5× | Chú thích, nhãn KPI, trục biểu đồ |
> Web hơi lớn hơn In (16px ≈ 12pt vs 11pt) — bình thường: màn hình đọc xa hơn giấy. Cùng vai trò Body, mỗi môi trường neo cỡ phù hợp.
>
> **6 tầng heading** (map thẳng Word Heading 1-6 / HTML h1-h6). H1-H3 phân biệt bằng **cỡ**; H4-H6 cụm gần body, phân biệt bằng **độ đậm + chữ hoa** (mắt không phân biệt rõ 6 cỡ chữ khác nhau). Thang H4-H6: **H4 Bold 700 → H5 SemiBold 600 → H6 SemiBold 600 + CHỮ HOA** (mỗi cấp đổi đúng 1 tín hiệu so với cấp trên; KHÔNG in nghiêng — bảo toàn dấu tiếng Việt ở cỡ nhỏ + sống sót khi in đen trắng). `[fact]` Word built-in cũng dùng italic ở cấp sâu; brand chọn weight+caps thay italic vì 2 lý do trên.
>
> **Cỡ theo thiết bị KHÔNG khoá ở L1:** L1 định *tỷ lệ* (1.25) + *vai trò* + cỡ tham chiếu; co giãn responsive theo màn hình (web/HMI/in) là việc **L2** per platform. L1 = hệ, không phải cỡ chết per device.
>
> **Cột "Line-height" = giá trị web/CSS.** Setting Word-native (Single / Multiple) KHÁC — Word "Multiple N" = N × line-unit font (Montserrat 1.219×), không phải N × cỡ chữ. Xem §2.2.4 bảng ánh xạ Office (body Word = Multiple 1.15, heading lớn = Single).
#### 2.2.3 Role × surface mapping (áp dụng đa nền tảng)
| Vai trò | Office (Word/Excel) | Web (Dashboard) | HMI (màn hình máy) | In ấn |
|---|---|---|---|---|
| Display | (hiếm dùng) | hero/h1 trang | tiêu đề splash | tiêu đề poster |
| Heading 1-6 | h1-h6 tài liệu | h1-h6 dashboard | tiêu đề màn hình | tiêu đề mục/tiểu mục |
| Body | đoạn văn (11pt) | nhãn dữ liệu, đoạn | chữ nút, giá trị | đoạn văn |
| Label | caption | nhãn KPI, nhãn trục | chỉ báo | chú thích chân |
#### 2.2.4 Line-height tiếng Việt + ánh xạ Office-native
**An toàn dấu chồng đến từ CHẾ ĐỘ cách dòng, KHÔNG từ một bội số.** `[fact]` (M1.7 §4.1 + Microsoft Support f25ed208): mọi chế độ tự nới (Single / Multiple / At least / Double) đều nới đủ chứa dấu chồng tiếng Việt (ầ ẫ ễ ộ ỡ ữ); **CHỈ "Exactly"** (cách dòng cứng) cắt đỉnh dấu. → Quy tắc an toàn = **dùng chế độ auto, tránh "Exactly"** (nếu buộc Exactly: ≥ ~1.25× cỡ chữ + render-test). Ngay cả Word "Single" (Montserrat = 1.219×) đã an toàn dấu — **"1.5×" KHÔNG phải sàn an toàn**, nó là mục tiêu đọc thoải mái cho web.
**Mục tiêu đọc thoải mái (tách khỏi an toàn dấu):**
- Body: vùng dễ đọc **120-145%** `[fact]` (Butterick). Web/HMI dùng 1.5× (màn hình đọc xa); Office body khác — xem ánh xạ dưới.
- Heading: chặt hơn body (chữ to cần ít leading).
**Ánh xạ Office-native — Word "Multiple N" ≠ CSS N** `[fact]` (M1.7 §1): Word nhân N với *line-unit của font*, mà Montserrat line-unit = **1.219×** (do cờ OpenType USE_TYPO_METRICS), KHÔNG phải 1.0. Hệ quả cho `.dotx`:
| Vai trò | Web/CSS (pitch) | Word (Montserrat) | Pitch thực |
|---|---|---|---|
| Body | line-height 1.5 | **Multiple 1.15** | 1.40× |
| Heading lớn (Title / H1-H4) | 1.1-1.3 | **Single** | 1.219× |
| Heading nhỏ (H5/H6 = cỡ body) | 1.4 | **Multiple 1.15** | 1.40× |
> Web giữ 1.5× (không đổi); Office body = 1.40× (Multiple 1.15). **CẤM Word "1.5 lines" cho body** (= 1.829× Montserrat, quá rộng — đây là bug S44). Nguồn: `50-plans/phenikaa-x-office-templates/research-office-typography.md` §1-§2 (đo OS/2 + ECMA-376) + owner ratify S45.
`[fact]` Latin body 1.4-1.5 (Bringhurst). `[inference]` Buffer riêng cho tiếng Việt KHÔNG cần khi dùng chế độ auto (engine tự nới) — chỉ cần tránh "Exactly" (`research-method-d-standard.md` §4.2).
#### 2.2.5 Quy tắc dùng (binding)
1. Heading + body **cùng family Montserrat** (an toàn, per `research-method-d-standard.md` §4.3). KHÔNG trộn 2 font sans khác nhau.
2. Phân cấp bằng **cỡ + độ đậm**, không bằng màu đơn thuần.
3. Viết hoa chữ đầu câu tiêu đề; KHÔNG drop-shadow/viền chữ (per `visual-from-raw.md` §13).
**Nguồn:** `visual-from-raw.md` §12 (Montserrat) + §13 (headline Bold 1.1× / body Regular 1.3×, owner ratify) + `research-method-d-standard.md` §4.1 (modular scale Bringhurst `[fact]`) + §4.2 (VN line-height) + §4.3 (font pairing Hoefler `[fact]`) + thực tế owner Word 11pt body. Endorsed ref: Carbon Productive + Fluent 2 ramp `[fact]`. (Material 3 de-based per `research-record-phase-1.md` §P1.2 — KHÔNG dùng.)
### 2.3 Spacing — lưới 8px
> [!NOTE] Thuật ngữ: **lưới 8px (8px grid)** = mọi khoảng cách là bội số của 8 · **token** = tên biến cho 1 giá trị · **macro whitespace** = khoảng nghỉ giữa các khối lớn · **micro whitespace** = khe hẹp bên trong 1 thành phần.
Đơn vị gốc = **8px**. Bước nửa 4px cho phép trong khe hẹp của component. 2 cột đơn vị (Web/HMI px · Office xấp xỉ pt, 1pt ≈ 1.33px):
| Token | Web/HMI (px) | Office (≈pt) | Dùng cho |
|-------|------|------|----------|
| `space.0` | 0 | 0 | Không cách |
| `space.xxs` | 4 | 3 | Khe hẹp trong component (icon↔nhãn, padding chip) — nửa bước |
| `space.xs` | 8 | 6 | Padding nhỏ, gap giữa 2 item liền kề |
| `space.sm` | 12 | 9 | Gap trong nhóm field |
| `space.md` | 16 | 12 | Padding thẻ mặc định, gap giữa đoạn |
| `space.lg` | 24 | 18 | Gap giữa nhóm / section nhỏ |
| `space.xl` | 32 | 24 | Padding trang, gap giữa section lớn |
| `space.2xl` | 48 | 36 | Khoảng nghỉ lớn (hero, page block) |
| `space.3xl` | 64 | 48 | Tách biệt khối chính (landing, dashboard sheet) |
**Quy tắc (binding):**
1. Mọi margin/padding/gap = bội số **8px** (ngoại lệ duy nhất: `space.xxs` 4px cho khe component).
2. Macro whitespace (giữa section) ≥ 2× micro whitespace (trong component) — bảo toàn phân cấp thị giác.
3. Macro spacing co theo breakpoint (web/HMI); Word/print KHÔNG co (trang vật lý cố định).
**Nguồn:** `research-method-d-standard.md` §6.2 (chọn lưới 8pt cross-deliverable) + 8pt baseline Material/Fluent `[fact]`.
> [!NOTE] **Office-native:** "Lưới 8px" trong Word = **lưới 6pt** (8px × 0.75 = 6pt; 1pt = 20 twip). Cột "Office ≈pt" ở bảng trên đã đúng đơn vị (8→6, 16→12, 24→18…) — convert sạch, không cần tính lại. `[fact]` (M1.7 §5.1).
#### 2.3.1 Núm 1 — Mật độ (density), 3 mức
> [!NOTE] Thuật ngữ: **density mode** = mức nén bố cục. **comfortable/compact** = tên chuẩn ngành.
Cùng thang gốc, đổi *bậc* dùng cho padding/gap component (KHÔNG thêm token — chỉ preset chọn bậc). Giá trị gán per mức = `[inference]` (snap lưới 8px, verify reality-test khi dựng template):
| Mức | Dùng cho | Padding thẻ | Gap nhóm |
|---|---|---|---|
| **Thoáng** (comfortable) | Tài liệu đọc kỹ, ít dữ liệu (Manual, training, proposal) | `space.lg` 24px | `space.md` 16px |
| **Vừa** (default) | Mặc định cân bằng (FAT/SAT, slide) | `space.md` 16px | `space.sm` 12px |
| **Dày** (compact) | Bảng dữ liệu đặc (Datasheet, BOM, data-table) | `space.xs` 8px | `space.xxs` 4px |
> **Nguồn `[fact]`:** Material Design 3 (default/comfortable/compact) + Cloudscape (comfortable/compact data table).
#### 2.3.2 Núm 2 — Kích thước màn hình (responsive)
> [!NOTE] Thuật ngữ: **responsive** = bố cục tự điều chỉnh theo bề rộng màn hình. **macro/micro whitespace** = khoảng nghỉ giữa khối lớn / khe trong 1 component.
Spacing **macro** (giữa khối lớn) co 1 bậc khi màn hẹp dần; **micro** (trong component) giữ nguyên:
| Breakpoint (§3.4) | Gutter/gap macro |
|---|---|
| `desktop` >1024px | `space.xl` 32px |
| `tablet` 640-1024px | `space.lg` 24px |
| `mobile`/HMI nhỏ <640px | `space.md` 16px |
> **Nguồn `[fact]`:** Utopia (fluid spacing). **CHỈ áp web/HMI** (màn co giãn). **Word/print KHÔNG co** (trang A4 cố định — xem §2.3.3). L1 định *quy tắc*; *cách* co (clamp, gộp cột) = **L2** team dev (per §3.4).
#### 2.3.3 Núm 3 — Môi trường (Word pt cố định vs web/HMI px co giãn)
Word đo spacing dọc bằng "space before/after paragraph" + "line spacing" (paragraph model), KHÔNG bằng box padding như web:
| Môi trường | Đơn vị | Co giãn (núm 2)? | Token |
|---|---|:---:|---|
| Web / Dashboard | px | ✅ | `space.*` (px) |
| HMI | px | ✅ | `space.*` (px) |
| Word / In ấn | pt | ❌ (A4 cố định) | `space.*` (cột pt) **+ token Word-native** |
**Token Word-native:**
| Token | Giá trị | Vai trò | Nguồn |
|---|---|---|---|
| `space.word.para-gap` | 6pt (= `space.xs` Office) | Khoảng sau mỗi đoạn (space after paragraph) | `[fact]` Word default 8pt / business 6-12pt → snap 6pt `[inference]` |
| `space.word.section-gap` | 12pt (= `space.md`) | Khoảng trước heading mục | `[inference]` neo lưới + nhịp dọc §2.3.4 |
| `space.word.line` | Multiple 1.15 (1.40× Montserrat) | Giãn dòng body | `[fact]` §2.2.4 ánh xạ Office — Word "Multiple 1.15", KHÔNG "1.5 lines" |
> Density (núm 1) áp Word qua các token này: thoáng = section 18pt/para 9pt · vừa = 12/6 · dày = 9/3 `[inference]` (verify template Phase 4).
#### 2.3.4 Nhịp dọc (vertical rhythm) + ngoại lệ nhà nước
**Nhịp dọc:** gap giữa section gắn line-height body — 2 tầng độc lập (KHÔNG hạ cấp nguồn research-grade):
- **Sàn cứng (binding):** macro ≥ **2×** micro (ngưỡng tối thiểu giữ phân cấp). `[inference]`
- **Mục tiêu khuyến nghị:** macro = **4-8×** micro `[fact]` NNGroup — áp density mặc định/thoáng.
- **Ngoại lệ compact:** density Dày tiệm cận sàn 2× — precedent Material 3 compact `[fact]` (owner ratify S36).
**Ngoại lệ văn bản hành chính nhà nước** (cùng nhóm "format pháp lý bên ngoài, brand KHÔNG override" với font Times New Roman §2.2.1): công văn/tờ trình/hồ sơ thầu nhà nước → theo **NĐ 30/2020/NĐ-CP** `[fact]`: font Times New Roman, mã TCVN 6909:2001, cỡ 13-14, lề trên/dưới 20-25mm/trái 30-35mm/phải 15-20mm, số trang giữa chân trang. **NĐ 30 KHÔNG quy định giãn dòng** `[fact]` — "1.5 dòng" là thông lệ kế thừa từ TT 01/2011/TT-BNV (**đã hết hiệu lực 15/6/2020**), không ràng buộc pháp lý hiện hành. Phenikaa-X (cổ phần tư nhân) không thuộc đối tượng bắt buộc `[fact]`, nên tuân khi tài liệu vào quy trình văn thư nhà nước `[inference]`. (M1.7 §4.2)
### 2.4 Elevation + shadow — 3 mức tối giản
Phenikaa-X **nghiêng phẳng**: logo + chữ **CẤM** đổ bóng (per `visual-from-raw.md` §13 + don't #6). Elevation chỉ áp dụng cho **bề mặt UI** (thẻ, popup), KHÔNG cho logo/chữ. Bản in (print) = luôn phẳng (giấy không có lớp).
| Token | Mức | Shadow (CSS) | Dùng cho |
|-------|-----|--------------|----------|
| `elevation.0` | Phẳng | `none` | Mặc định · nền · print · HMI phẳng |
| `elevation.1` | Nổi nhẹ | `0 1px 3px rgba(22,24,36,.12)` | Thẻ, KPI tile, panel |
| `elevation.2` | Lớp phủ | `0 4px 12px rgba(22,24,36,.18)` | Modal, dropdown, tooltip, popover |
> [!NOTE] **Dark mode:** shadow gần như vô hình trên nền tối → dùng **viền 1px `neutral-700`** thay shadow để tách lớp (pattern Fluent dark) `[inference]`.
**Nguồn:** Fluent depth = shadow-only, KHÔNG tint overlay (khác Material 3 — đã de-base `research-record-phase-1.md` §P1.2) `[fact]`; nghiêng phẳng của brand `visual-from-raw.md` §13.
### 2.5 Border + radius
#### Border (viền)
| Token | px | Dùng cho |
|-------|----|----|
| `border.0` | 0 | Không viền — **ưu tiên** (nguyên tắc closure: gợi khối bằng nền + khoảng trắng, không cần viền đầy) |
| `border.1` | 1px | Mặc định: field, ô bảng, divider |
| `border.2` | 2px | Nhấn: trạng thái active |
| `border.accent` | 4px (1 cạnh) | Dải nhấn 1 bên (KPI tile mép trái) — gợi container mà không cần viền 4 cạnh |
| `border.focus` | 2px (offset 2px) | Vòng focus bàn phím — màu `blue-500` (light) / `blue-200` (dark), tương phản ≥3:1 với nền kề (WCAG 2.2 §2.4.13) `[fact]` |
**Quy tắc:** tránh viền đầy 4 cạnh dư thừa (anti-pattern "chartjunk"). Ưu tiên khoảng trắng + nền phân tách.
#### Radius (bo góc)
| Token | px | Dùng cho |
|-------|----|----|
| `radius.none` | 0 | Mặc định công nghiệp/HMI — thẩm mỹ AMR sắc cạnh |
| `radius.sm` | 2 | Bo nhẹ: chip, badge, input |
| `radius.md` | 4 | Thẻ, panel (Office/dashboard) |
| `radius.lg` | 8 | Modal, dialog lớn |
> [!NOTE] **Bo góc tùy bề mặt (L2 quyết định cuối):** HMI/biển công nghiệp nghiêng `radius.none` (sắc cạnh, kỹ thuật); tài liệu Office/dashboard nghiêng `radius.md` 4px (thẻ mềm, thân thiện). L1 cung cấp thang; L2 chọn mặc định theo từng nền tảng.
>
> **Office-native:** trong Word chỉ **shape / text-box** bo góc được (qua `a:prstGeom roundRect`); **đoạn văn thường + ô bảng KHÔNG bo** (= `radius.none`). Radius chỉ áp cho KPI tile/khối đồ hoạ, không áp body/table. `[fact]` (M1.7 §5.3).
**Quy tắc tương tác (hover/active/disabled):** chỉ định nghĩa **token nền** ở đây (`border.focus`); ma trận trạng thái đầy đủ per-component thuộc **L2** (platform implementation). Consensus viền mặc định 1px (Carbon/Material/Fluent) `[fact]`.
### 2.6 Bộ icon
> [!NOTE] Thuật ngữ: **bộ icon (icon set)** = một thư viện icon thiết kế đồng bộ một phong cách · **Regular** = kiểu viền rỗng (nét vẽ đường viền) · **Filled** = kiểu tô đặc (khối đặc) — đây là tên Fluent dùng · **currentColor** = SVG kế thừa màu chữ của phần tử cha (đổi màu qua token, không sửa file) · **tương phản non-text** = chỉ số WCAG đo độ rõ của đồ hoạ mang nghĩa (icon, viền) trên nền.
Bộ icon canonical Layer 1 = **Fluent UI System Icons** (Microsoft, giấy phép MIT) — nguồn icon duy nhất cho mọi bề mặt. Chọn vì: phong cách hình học bo nhẹ thân thiện (cùng gia đình Fluent 2 — chuẩn chính của hệ, `research-record-phase-1.md §P1.2`) · có sẵn **đầy đủ cả Regular + Filled** nên không phải trộn thư viện · gói NuGet chính chủ Microsoft hợp stack dev (MudBlazor) · bên bảo trì bền vững. Tên icon cụ thể (`bot-24-regular`, `warning-24-filled`...) là **L2** — không khoá ở đây (xem §4 cơ chế áp dụng).
**Thang cỡ icon** — neo thẳng cột pt vai trò type scale §2.2 (cùng *neo per-môi-trường*, KHÔNG theo tỷ lệ ÷1.33 cứng của spacing §2.3):
| Token | Web/HMI (px) | Office (pt) | Khớp vai trò §2.2 | Dùng cho |
|---|---|---|---|---|
| `icon.sm` | 16 | 11 | Body | Inline cạnh chữ body · nhãn · chip |
| `icon.md` | 20 | 14 | Heading 3 | **Mặc định UI** — nút, menu, KPI tile |
| `icon.lg` | 24 | 18 | ≈ Heading 2 (25px) | Tiêu đề · mục tiêu chạm (touch) · HMI nhìn xa |
> Icon **inline** (chèn giữa dòng chữ) lấy cỡ bằng chữ kế bên, không cố định bậc. Cỡ theo thiết bị co giãn = **L2** (giống §2.2 type scale).
**Quy tắc (binding) — 6 luật cốt lõi:**
1. **Một nguồn duy nhất** — mọi icon từ Fluent UI System Icons, KHÔNG trộn bộ khác.
2. **Hai kiểu, chọn theo tình huống** (KHÔNG khoá mặc định cứng): **Regular** (viền rỗng) cho icon thường/phẳng · **Filled** (tô đặc) cho nhấn mạnh / trạng thái active / HMI nhìn xa / nơi cần lắp êm vào MudBlazor. Trong cùng một ngữ cảnh, giữ một kiểu nhất quán.
3. **Cỡ icon** theo thang trên, neo thẳng cột pt vai trò §2.2 (icon.sm=Body 11pt · icon.md=H3 14pt · icon.lg≈H2 18pt) — KHÔNG dùng tỷ lệ ÷1.33 của spacing.
4. **Màu icon = token**, KHÔNG hardcode hex: dùng `text.*` / `brand.*` / semantic theo ngữ cảnh; icon phụ (trang trí, ít quan trọng) = `neutral-400`. SVG dùng `currentColor` để kế thừa.
5. **Icon mang chức năng/trạng thái luôn kèm nhãn** (chữ hoặc tooltip) — theo WCAG 1.4.1 (use of color); kế thừa §2.1.3 (semantic, file này) + nguyên tắc "không chỉ dựa vào màu" §1.4. Khe icon↔nhãn = `space.xxs` 4px.
6. **Tương phản non-text ≥ 3:1** cho icon mang nghĩa (WCAG 2.2 §1.4.11). Icon trang trí thuần (không mang thông tin) miễn trừ.
> Lưu ý DRY: 6 luật **tái dùng** token đã có (theme alias §2.1.4 + type scale §2.2 + `space.xxs` §2.3 + luật WCAG §1.4 / §2.1.3) — chỉ thêm họ token cỡ icon. Danh sách "khái niệm → tên icon Fluent" là tài liệu tra cứu riêng (L2), dựng khi deliverable thật cần (D125 kích hoạt theo thực tế).
**Nguồn:** `spec-icon-subphase.md` (D150-D154 — chọn Fluent + 6 luật, owner ratify S37) + `research-method-d-amr-hmi-web.md` §3.5 (rubric Q10 icon-set-consistent) + `research-method-d-standard.md` §5.2 (WCAG non-text 3:1) + `[fact]` [Fluent UI System Icons (GitHub, MIT)](https://github.com/microsoft/fluentui-system-icons) · [WCAG 2.2 §1.4.11 non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) · [WCAG 1.4.1 use of color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html).
## 3. Bố cục cơ bản
> Bố cục L1 đứng trên **3 trụ nghiên cứu**: (1) nguyên tắc tri giác Gestalt, (2) phân cấp thị giác, (3) hệ lưới — đều có nguồn học thuật. KHÔNG dựa trên bản phác ý tưởng in ấn (xem §3.3 cảnh báo).
### 3.1 Nguyên tắc bố cục — Gestalt + phân cấp thị giác
> [!NOTE] Thuật ngữ: **Gestalt** = nhóm nguyên tắc tâm lý mô tả cách não gom element rời thành nhóm · **phân cấp thị giác (visual hierarchy)** = thứ tự mắt nhìn element.
**Gestalt — não nhóm element thế nào** `[fact]` (Wertheimer 1923):
| Nguyên tắc | Nghĩa | Áp dụng bố cục Phenikaa-X |
|-----------|-------|----------------------------|
| Gần gũi (proximity) | Element gần nhau = cùng nhóm | Gom cụm KPI bằng khoảng cách (token §2.3), không cần viền |
| Tương tự (similarity) | Cùng màu/shape/size = cùng loại | Series chart cùng màu = cùng nhóm đọc |
| Đóng kín (closure) | Não tự khép hình thiếu | Dải nhấn `border.accent` 4px gợi khối — không cần viền 4 cạnh |
| Hìnhnền (figure-ground) | Element nổi trên nền | Khối `blue-500` + chữ trắng = tương phản rõ |
**Phân cấp thị giác — mắt đọc theo đường nào** `[fact]` (Nielsen Norman Group):
- **F-pattern** — dashboard, báo cáo, trang nhiều chữ (quét trái→phải dòng trên → xuống → dọc lề trái).
- **Z-pattern** — slide bìa, proposal, marketing ít chữ (4 góc). `[unverified]` bằng chứng yếu hơn F — coi là guideline.
- **Phân cấp bằng:** cỡ (tiêu đề/thân ≥ 2.5×) + tương phản màu + khoảng cách. KHÔNG phân cấp bằng màu đơn thuần.
**Nguồn:** `research-method-d-standard.md` §2 (Gestalt, Wertheimer 1923 `[fact]`) + §3 (F/Z-pattern, NNGroup `[fact]`).
### 3.2 Hệ lưới — chọn loại theo ngữ cảnh + lưới 12 cột
> [!NOTE] Thuật ngữ: **cột (column)** = dải dọc chia bố cục · **gutter** = khe giữa 2 cột.
**Chọn loại lưới theo ngữ cảnh** `[fact]` (Müller-Brockmann 1981):
| Loại lưới | Dùng cho |
|-----------|----------|
| Manuscript (1 cột) | Tài liệu văn xuôi: proposal, báo cáo thân bài |
| Column (nhiều cột) | Slide, newsletter, brochure |
| Modular (lưới 2D) | Dashboard, gallery thẻ, web card |
| Hierarchical (bất quy tắc) | Hero section, trang điều hướng (wayfinding) |
**Lưới số mặc định cho digital = 12 cột** (chia hết 2/3/4/6 → 2 nửa, 3 phần ba, 4 phần tư). Gutter neo token §2.3:
| Thuộc tính | Giá trị |
|------------|---------|
| Số cột | 12 (Fluent + Material `[fact]`) |
| Gutter desktop | `space.lg` 24px |
| Gutter mobile/HMI | `space.md` 16px |
| Bề rộng nội dung tối đa | ~1200px (tránh dòng quá dài) |
**Ví dụ:** 3 ô KPI = 4+4+4 cột · nội dung 8 cột + sidebar 4 cột · 2 nửa = 6+6.
**Nguồn:** Müller-Brockmann grid taxonomy `[fact]` + Fluent/Material 12-col `[fact]` (KHÔNG dùng Carbon 16-col — Fluent chuẩn chính `research-record-phase-1.md` §P1.2).
### 3.3 Khối bố cục (layout primitives) — digital
46 khối **composable** (ghép lồng nhau), mỗi khối tự co giãn theo nội dung. Mọi khoảng cách neo token §2.3 (DRY):
| Primitive | Vai trò | Khoảng cách |
|-----------|---------|-------------|
| **Container** | Khung giới hạn bề rộng + padding ngoài | `space.xl` 32px (desktop) |
| **Stack** | Xếp dọc, đều khoảng cách | gap = token §2.3 |
| **Inline** | Xếp ngang, tự xuống dòng (nút, chip, tag) | gap = `space.sm`/`space.md` |
| **Grid** | Lưới 2D (thẻ dashboard, gallery) | gap = gutter §3.2 |
**Nguồn:** composable layout primitives = chuẩn ngành `[fact]` ([Every Layout](https://every-layout.dev/rudiments/composition/) · [Atlassian primitives](https://atlassian.design/components/primitives/overview)).
> [!WARNING] **Bản in KHÔNG phải nguồn L1.** `applied-from-raw.md` §2 ("5 khối bố cục") là **bản phác ý tưởng in ấn trích từ guideline tập đoàn** (dùng motif 2:5 + font UTM Avo — cả hai Phenikaa-X KHÔNG dùng), ngôn ngữ "gợi ý / có thể". Đây là **điểm khởi đầu cho template in ở Phase 2**, KHÔNG phải primitive L1 ràng buộc. Khi Phase 2 dựng template in → re-research + chuyển motif sang LiDAR + font sang Montserrat, owner ratify.
### 3.4 Breakpoint — điểm tham chiếu (hành vi co giãn → L2)
> [!IMPORTANT] L1 chỉ định nghĩa **bộ điểm tham chiếu** (từ vựng chung). **Hành vi co giãn thật** (cột nào gộp, font scale theo màn hình) thuộc **L2** — nhất quán quyết định §2.2 "kích thước theo thiết bị = L2".
| Tên | Bề rộng màn hình | Ngữ cảnh |
|-----|------------------|----------|
| `mobile` | < 640px | Điện thoại, HMI nhỏ |
| `tablet` | 6401024px | Tablet, HMI tầm trung |
| `desktop` | > 1024px | PC, dashboard lớn |
**Quy tắc:** L1 cấp 3 mốc làm vocabulary chung; L1 KHÔNG quy định "mobile lưới 12 thành mấy cột" — việc đó L2 (per nền tảng). `[inference]`
## 4. Cơ chế áp dụng
2 cơ chế kết hợp, phủ ~80% trường hợp dùng (theo spec §8.7, quyết định D130):
### 4.1 Cơ chế 1 — Tự nạp thụ động (passive)
`@_foundation/design-language.md` đã thêm vào `70-company/CLAUDE.md` (cùng kiểu với `@index.md`).
- **Kích hoạt:** Claude tự nạp tài liệu này khi làm việc trong `70-company/`.
- **Phạm vi:** output thương hiệu trong 70-company (asset logo, template thương hiệu, rà soát governance).
- **Chi phí:** ~744 dòng nạp mỗi phiên làm việc trong 70-company (đã tăng ~1.7× so với ~430 dòng Phase 1 sau khi thêm §2.1.7 data-viz + §2.3.1-4 spacing + §2.6 icon + các errata) — vẫn chấp nhận được, nhưng là yếu tố cân nhắc cho §4.3 (vault rule paths-glob) nếu tiếp tục phình.
### 4.2 Cơ chế 2 — Skill gọi tay (active)
Skill `phenikaa-x-design-apply` đặt tại `.claude/skills/phenikaa-x-design-apply/SKILL.md`.
- **Kích hoạt:** anh gõ `/phenikaa-x-design-apply` khi dựng deliverable mới.
- **Đầu ra:** thiết kế đã áp dụng + bảng tự kiểm 5 câu + trích nguồn quy tắc.
- **Phạm vi:** Dashboard, báo cáo FAT, slide, email, brochure (mọi nơi ngoài 70-company).
### 4.3 Vault rule paths-glob — HOÃN
Theo D125 "kích hoạt theo thực tế": chỉ thêm vault rule nếu Phase 5 pilot cho thấy anh quên gõ skill nhiều lần.
## 5. Cross-references
### Nguồn căn cứ (per spec §6.1)
Foundation 4 tầng (đã ratify):
- `visual-from-raw.md` — spec màu / typography / logo / motif
- `identity-from-raw.md` — 6 giá trị cốt lõi (truy nguyên nguyên tắc)
- `applied-from-raw.md` — giọng viết (truy nguyên nguyên tắc §1)
- `governance-from-raw.md` — 5 giá trị vận hành (dùng cho phạm vi rà soát governance §4; chưa cite ở §1-§3, reserved Phase 2+)
Nghiên cứu method D (ship S19):
- `50-plans/phenikaa-x-design-system/research-method-d-standard.md` — chuẩn ngành (Gestalt, grid taxonomy, lưới 8pt)
- `50-plans/phenikaa-x-design-system/research-method-d-community.md` — Carbon / Material 3 / Fluent 2 / Atlassian / Salesforce
- `50-plans/phenikaa-x-design-system/research-method-d-amr-hmi-web.md` — đặc thù AMR / HMI / Web
- `50-plans/phenikaa-x-design-system/research-method-d-rubric.md` — rubric kiểm Q1-Q5
### Nguồn spec
- `50-plans/phenikaa-x-design-system/spec-design-language-stream.md` — spec brainstorm ratify 2026-05-31
- `50-plans/phenikaa-x-design-system/plan.md` — plan stream 5 Phase
- `50-plans/phenikaa-x-design-system/research-record-phase-1.md` — nhật ký audit Phase 1
### Cơ chế áp dụng
- `70-company/CLAUDE.md``@_foundation/design-language.md` tự nạp thụ động
- `.claude/skills/phenikaa-x-design-apply/SKILL.md` — gọi tay chủ động
## Lịch sử phiên bản
### v0.4.4 (2026-06-10) — Errata: hấp thụ token Caution + dọn nhất quán (audit workflow S59)
**Thêm (errata additive — KHÔNG đổi token cũ):**
- §2.1.2 — thêm bậc `neutral-550` `#6A6F88` (giữa `neutral-500``neutral-600`); `border.strong` dark nay trỏ token này thay vì hardcode hex `#6A6F88` — khôi phục nguyên tắc §2.1.4 "alias trỏ token, KHÔNG hardcode hex".
- §2.1.3 — thêm biến thể `caution.fill` `#F9A900` (nền vàng callout "Thận trọng" ANSI Y2 / ISO 3864 signal yellow), buộc chữ đen `neutral-900`. Đối xứng tiền lệ `warning.icon` (v0.4.2): trước chỉ nhắc trong changelog prose, nay thành token chính thức §2.1.3 + WCAG §2.1.6.
- §2.1.6 — thêm 2 dòng WCAG cho `caution.fill` (8.99:1 / `neutral-900` AAA · 1.96:1 / white FAIL → buộc chữ đen).
**Sửa (errata — số liệu lỗi thời, KHÔNG đổi token):**
- §2.1.6 — đồng bộ cặp chữ trắng / `orange-500` + `border.strong` `neutral-450` / white: bảng ghi `3.2:1``3.15:1` (giá trị thực công thức W3C; làm tròn lên 3.2 che mức sát ngưỡng 3:1, đã khớp callout phân tầng cam vốn ghi 3.15).
- §4.1 — "~430 dòng" → "~744 dòng" (số dòng thực sau Phase 2/3 + icon + errata; chi phí auto-load eager tăng ~1.7×).
**Nguồn:** workflow kiểm chứng độc lập 8 chiều (S59 2026-06-10 — 10/21 phát hiện qua phản biện được xác nhận) + `research-status-colors-office.md` §6 (#F9A900 = 8.99:1 / chữ đen, D202) + `[fact]` công thức relative luminance W3C (tự tính lại #F9A900 + neutral-550) + owner ratify S59 2026-06-10. Nhãn H1/frontmatter giữ `v0.4` theo chính sách errata-no-bump; thêm dòng trỏ dưới H1.
### v0.4.3 (2026-06-10) — Errata: sửa thuật ngữ + điều kiện `error.text-sm`
**Sửa (errata — KHÔNG đổi token/giá trị màu; KHÔNG bump frontmatter, đúng tiền lệ v0.4.1/v0.4.2):**
- §2.1.3 — thuật ngữ nguyên nhân nhoè đỏ nhỏ: **"chromostereopsis" → "tán sắc ngang (TCA — chromatic aberration)"**. Chromostereopsis là ảo giác chiều sâu đỏ/xanh (cần màu đối nghịch kế bên), KHÔNG phải cơ chế nhoè chữ đỏ trên nền sáng. Đồng bộ `research-status-colors-office.md` §3 (đã ghi TCA từ S56).
- §2.1.3 — làm rõ **điều kiện cần `error.text-sm`**: chỉ khi HỘI ĐỦ cỡ nhỏ + nét mảnh + bão hoà cao + nền sáng; nêu rõ **nền tối gần như không bị** (tán sắc không thấy trên nền đen) → dark-mode giữ đỏ sáng, không cần trầm.
**Nguồn:** `[fact]` [UXmatters — color theory digital displays](https://www.uxmatters.com/mt/archives/2007/01/applying-color-theory-to-digital-displays.php) · [MIT 6.813 Reading 16 Color](https://web.mit.edu/6.813/www/sp16/classes/16-color/) · [W3C WCAG3 visual contrast of text](https://www.w3.org/WAI/GL/WCAG3/2021/how-tos/visual-contrast-of-text/) (đỏ/xanh tán sắc, nhoè bão hoà rõ nhất nền trắng — không thấy nền đen) + `research-status-colors-office.md` §3 + owner ratify S58 2026-06-10.
### v0.4.2 (2026-06-08) — Errata: token `warning.icon` cho callout office
**Thêm (errata additive — KHÔNG đổi token cũ):**
- §2.1.3 — thêm biến thể `warning.icon` `#CB6119` (cam ISO, cho icon + tiêu đề callout cảnh báo cỡ lớn); giữ `warning #9A5B00` cho chữ/nhãn nhỏ. Đối xứng tiền lệ `error`/`error.text-sm`.
- §2.1.6 — thêm 2 dòng WCAG cho `warning.icon` (3.99:1 / white · 3.76:1 / `neutral-50` — đạt ngưỡng 3:1 chữ lớn/icon, `[fact]` công thức W3C).
**Nguồn:** `50-plans/phenikaa-x-office-templates/research-status-colors-office.md` (§6 quyết định cuối + owner ratify S56 2026-06-08). Callout office dùng `warning.icon` cho Cảnh báo; Thận trọng dùng nền vàng `#F9A900` + chữ đen (Y2 ANSI).
### v0.4.1 (2026-06-04) — Errata typography Office-native (M1.7)
**Sửa (errata — KHÔNG đổi token, chỉ làm rõ Office mapping + sửa cite pháp lý):**
- §2.2.4 — viết lại: an toàn dấu chồng VN đến từ **chế độ auto** (tránh "Exactly"), KHÔNG từ bội số 1.5×; thêm bảng **ánh xạ Office** (Word "Multiple N" ≠ CSS N vì Montserrat line-unit = 1.219×, cờ USE_TYPO_METRICS). Body Word = **Multiple 1.15** (1.40×); heading lớn = **Single** (1.219×).
- §2.2.2 — note cột line-height = giá trị web/CSS; Word-native xem §2.2.4.
- §2.3 — note "lưới 8px" Office = lưới 6pt (cột pt đã đúng).
- §2.3.3 — `space.word.line` 1.5 → Multiple 1.15; bỏ cite TT 01/2011 sai.
- §2.3.4 — **NĐ 30 KHÔNG quy định giãn dòng**; "1.5 dòng" kế thừa TT 01/2011 (**đã hết hiệu lực 15/6/2020**).
- §2.5 — note radius: Word chỉ shape/text-box bo được, body+bảng KHÔNG bo.
**Nguồn:** `50-plans/phenikaa-x-office-templates/research-office-typography.md` (M1.7 — đo OS/2 binary + ECMA-376 + NĐ30 Phụ lục I) + owner ratify S45 2026-06-04.
### v0.4 (2026-06-01) — Sub-phase icon: chọn bộ icon Fluent + 6 luật cốt lõi
**Ship (additive — Minor bump, KHÔNG đụng token cũ):**
- §2.6 mới — Bộ icon canonical = **Fluent UI System Icons** (Microsoft, MIT). Thêm thang cỡ `icon.sm/md/lg` (16/20/24px) + 6 luật cốt lõi (một nguồn · 2 kiểu Regular/Filled theo tình huống · cỡ neo type scale · màu = token · icon kèm nhãn WCAG 1.4.1 · non-text ≥3:1). Tái dùng token sẵn có, không tạo token màu/spacing mới.
**Defer reality-activated:** icon AMR đặc thù (AMR/AGV/LiDAR/dock/mission) · bảng tra "khái niệm → tên Fluent" (L2) · luật nâng cao (keyline grid, ma trận state).
**Reconcile nhãn version:** icon ship trước Phase 4 → icon nhận **v0.4**; Phase 4 (Pattern FAT report) rebase **v0.5** khi ship (per `spec-icon-subphase.md` §4 + reviewer S37 MAJOR-1). Đã sync `plan.md` Phase 4 (v0.4→v0.5).
**Quyết định nguồn:** `50-plans/phenikaa-x-design-system/spec-icon-subphase.md` D150-D154 (brainstorm S37 + owner ratify Fluent + kiểu theo tình huống).
### v0.3 (2026-06-01) — Phase 3: thang màu sequential đều cảm nhận + mô hình spacing 3 núm
**Ship (additive — Minor bump):**
- §2.1.7.2 — Sequential ĐỔI từ "mượn thang brand" → brand-anchored đơn sắc đều L (OKLab), neo cứng `seq-700`≈Deep Blue + `seq-900`=Dark Blue. Trả "nợ" perceptual-uniform v0.2.
- §2.3 — Thêm mô hình spacing "1 thang gốc + 3 núm" (§2.3.1 density 3 mức · §2.3.2 responsive · §2.3.3 môi trường + token `space.word.*` · §2.3.4 nhịp dọc + ngoại lệ nhà nước). Bảng `space.0…3xl` GIỮ nguyên.
**Tách Phase sau (D146):** icon set → sub-phase riêng (thẩm mỹ risk cao, cần phiên tập trung).
**Quyết định nguồn:** `50-plans/phenikaa-x-design-system/spec-phase-3.md` D146-D149 (reviewer-clean iter1 + owner ratify S36).
### v0.2 (2026-06-01) — Phase 2: bảng màu data-viz + chữ đỏ nhỏ
**Ship (additive — Minor bump, KHÔNG đổi token v0.1 nào):**
- §2.1.7 mới — Data-viz 4 họ màu (categorical 4/6 + sequential + diverging) + alias `dataviz.*`
- §2.1.3 thêm biến thể `error.text-sm` (chữ đỏ nhỏ chống rung viền) — không đổi nghĩa `error`
- §2.1.6 thêm 2 dòng WCAG cho `error.text-sm` (7.26:1 sáng / 9.01:1 tối — AAA)
**Hoãn Phase 3 (v0.3):**
- Sequential perceptual-uniform (thang đều cảm nhận) (→ shipped v0.3)
- Spacing formal scale (→ shipped v0.3, mô hình 3 núm)
- Icon set (→ tách sub-phase riêng per D146)
**Quyết định nguồn:** `50-plans/phenikaa-x-design-system/spec-pattern-dashboard.md` D134D142 (reviewer agent duyệt 2 vòng) + `palette_eval.py` (số đo khách quan).
### v0.1 (2026-05-31) — Phase 1 bản nháp đầu
**Ship:**
- §1 Nguyên tắc nền tảng — 5 nguyên tắc (3 truy nguyên giá trị cốt lõi + 2 bảo đảm phạm vi Layer 1)
- §2 Token — màu (thang sắc độ + alias semantic + verify WCAG) / chữ (thang cỡ + role mapping + verify dấu tiếng Việt) / spacing lưới 8px / elevation 3 mức / border + radius
- §3 Bố cục — lưới 12 cột + 4 khối bố cục (Container/Stack/Inline/Grid) + 4 breakpoint
- §4 Cơ chế áp dụng — @file thụ động 70-company + skill gọi tay
**Hoãn Phase 2+:**
- Component (mảnh Lego)
- Pattern Dashboard (Phase 2)
- Data viz (Phase 3)
- Pattern báo cáo FAT (Phase 4)
**Cờ bất định thẩm mỹ (rà lại Phase 5 audit):**
1. §1 cách đặt tên nguyên tắc — chờ feedback pilot
2. §2.1 số bậc thang sắc độ — rà lại theo cycle-time S24
3. §2.2 cặp trọng lượng font — chờ feedback pilot
4. §2.4 thẩm mỹ elevation — chờ feedback pilot
5. §3.3 cách đặt tên khối bố cục — quy ước chủ quan

View File

@@ -0,0 +1,73 @@
USE [RobotInstaller];
GO
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
-- Additive/idempotent migration. Safe to run more than once.
IF OBJECT_ID(N'dbo.Notifications', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Notifications
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_Notifications PRIMARY KEY CLUSTERED
CONSTRAINT DF_Notifications_Id DEFAULT NEWSEQUENTIALID(),
RecipientUserId UNIQUEIDENTIFIER NOT NULL,
ActorUserId UNIQUEIDENTIFIER NULL,
ActorName NVARCHAR(200) NULL,
BroadcastId UNIQUEIDENTIFIER NULL,
EventType NVARCHAR(100) NOT NULL,
Severity NVARCHAR(20) NOT NULL
CONSTRAINT DF_Notifications_Severity DEFAULT N'info',
Title NVARCHAR(200) NOT NULL,
Message NVARCHAR(1000) NOT NULL,
EntityType NVARCHAR(50) NULL,
EntityId NVARCHAR(100) NULL,
ActionUrl NVARCHAR(1000) NULL,
ReadAt DATETIME2(3) NULL,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_Notifications_CreatedAt DEFAULT SYSUTCDATETIME(),
ExpiresAt DATETIME2(3) NULL,
CONSTRAINT FK_Notifications_RecipientUser
FOREIGN KEY (RecipientUserId) REFERENCES dbo.Users(Id) ON DELETE CASCADE,
CONSTRAINT CK_Notifications_Severity
CHECK (Severity IN (N'info', N'success', N'warning', N'error')),
CONSTRAINT CK_Notifications_EventType_NotBlank
CHECK (LEN(LTRIM(RTRIM(EventType))) > 0),
CONSTRAINT CK_Notifications_Title_NotBlank
CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
CONSTRAINT CK_Notifications_Message_NotBlank
CHECK (LEN(LTRIM(RTRIM(Message))) > 0)
);
END;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.Notifications')
AND name = N'IX_Notifications_Recipient_Read_Created'
)
BEGIN
CREATE INDEX IX_Notifications_Recipient_Read_Created
ON dbo.Notifications(RecipientUserId, ReadAt, CreatedAt DESC)
INCLUDE (Severity, Title, ActionUrl, ExpiresAt);
END;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE object_id = OBJECT_ID(N'dbo.Notifications')
AND name = N'IX_Notifications_BroadcastId'
)
BEGIN
CREATE INDEX IX_Notifications_BroadcastId
ON dbo.Notifications(BroadcastId)
WHERE BroadcastId IS NOT NULL;
END;
GO
PRINT N'RobotInstaller notification schema is ready.';
GO

View File

@@ -28,6 +28,7 @@ Không lưu mật khẩu thật vào file cấu hình. Khi chạy local, tạo f
| `dbo.PackageVersions` | Các version của từng package | | `dbo.PackageVersions` | Các version của từng package |
| `dbo.Applications` | App được đóng gói từ nhiều package | | `dbo.Applications` | App được đóng gói từ nhiều package |
| `dbo.ApplicationPackages` | Liên kết app-package, có thể chọn version cụ thể | | `dbo.ApplicationPackages` | Liên kết app-package, có thể chọn version cụ thể |
| `dbo.Notifications` | Thông báo riêng cho từng user và thông báo hệ thống do Admin đăng |
## Ràng buộc quan trọng ## Ràng buộc quan trọng
@@ -63,12 +64,15 @@ $env:SQLCMDPASSWORD = '<mat-khau-sa>'
sqlcmd -S 172.20.235.176 -U sa -b -i .\database\01_create_database.sql sqlcmd -S 172.20.235.176 -U sa -b -i .\database\01_create_database.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\02_schema.sql sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\02_schema.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\03_views.sql sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\03_views.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\04_notifications.sql
``` ```
Chạy các lệnh trên từ thư mục `web-server`. Chạy các lệnh trên từ thư mục `web-server`.
Khi dùng `sqlcmd` để seed/test dữ liệu, thêm `-I` hoặc bật `SET QUOTED_IDENTIFIER ON` vì schema có filtered index cho ràng buộc một latest version trên mỗi package. Khi dùng `sqlcmd` để seed/test dữ liệu, thêm `-I` hoặc bật `SET QUOTED_IDENTIFIER ON` vì schema có filtered index cho ràng buộc một latest version trên mỗi package.
`04_notifications.sql` là migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy file này; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại.
## Luồng dữ liệu đề xuất ## Luồng dữ liệu đề xuất
1. Upload package mới: 1. Upload package mới:

View File

@@ -225,6 +225,8 @@ button:disabled {
height: 56px; height: 56px;
justify-content: space-between; justify-content: space-between;
padding: 0 24px; padding: 0 24px;
position: relative;
z-index: 60;
} }
.topbar-left, .topbar-left,
@@ -240,6 +242,256 @@ button:disabled {
margin: 0; margin: 0;
} }
.notification-center {
position: relative;
}
.notification-button {
position: relative;
}
.notification-badge {
align-items: center;
background: #d92d20;
border: 2px solid #f8fafc;
border-radius: 999px;
color: #ffffff;
display: inline-flex;
font-size: 9px;
font-weight: 800;
height: 18px;
justify-content: center;
line-height: 1;
min-width: 18px;
padding: 0 4px;
position: absolute;
right: -7px;
top: -7px;
}
.notification-panel {
background: #ffffff;
border: 1px solid #dbe3ea;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
overflow: hidden;
position: absolute;
right: 0;
top: calc(100% + 10px);
width: min(400px, calc(100vw - 28px));
z-index: 120;
}
.notification-panel[hidden],
.notification-badge[hidden],
.notification-empty[hidden] {
display: none;
}
.notification-panel-header {
align-items: center;
border-bottom: 1px solid #eef2f7;
display: flex;
justify-content: space-between;
padding: 14px 16px 10px;
}
.notification-panel-header > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.notification-panel-header strong {
color: #111827;
font-family: "Manrope", Arial, sans-serif;
font-size: 15px;
}
.notification-panel-header span {
color: #64748b;
font-size: 11px;
}
.text-button {
background: transparent;
border: 0;
color: var(--primary);
font-size: 11px;
font-weight: 800;
padding: 5px;
}
.text-button:hover,
.text-button:focus-visible {
text-decoration: underline;
}
.notification-tabs {
border-bottom: 1px solid #eef2f7;
display: flex;
gap: 16px;
padding: 0 16px;
}
.notification-tabs button {
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
color: #64748b;
font-size: 11px;
font-weight: 800;
padding: 9px 0 7px;
}
.notification-tabs button.active {
border-bottom-color: var(--primary);
color: var(--primary);
}
.notification-list {
max-height: min(440px, calc(100vh - 190px));
overflow-y: auto;
}
.notification-item {
align-items: flex-start;
background: #ffffff;
border: 0;
border-bottom: 1px solid #f0f3f7;
color: inherit;
display: grid;
gap: 10px;
grid-template-columns: 34px minmax(0, 1fr) 7px;
padding: 12px 14px;
text-align: left;
width: 100%;
}
.notification-item:hover,
.notification-item:focus-visible {
background: #f8fafc;
outline: 0;
}
.notification-item.unread {
background: #f7f8ff;
}
.notification-item.unread:hover,
.notification-item.unread:focus-visible {
background: #eef1ff;
}
.notification-item-icon {
align-items: center;
background: var(--info-bg);
border-radius: 50%;
color: var(--info);
display: inline-flex;
height: 34px;
justify-content: center;
width: 34px;
}
.notification-item-icon .material-symbols-outlined {
font-size: 19px;
}
.notification-item.tone-success .notification-item-icon {
background: var(--success-bg);
color: var(--success);
}
.notification-item.tone-warning .notification-item-icon {
background: var(--warning-bg);
color: var(--warning);
}
.notification-item.tone-error .notification-item-icon {
background: var(--danger-bg);
color: var(--danger);
}
.notification-item-copy {
min-width: 0;
}
.notification-item-copy strong,
.notification-item-copy span,
.notification-item-copy time {
display: block;
}
.notification-item-copy strong {
color: #172033;
font-size: 12px;
line-height: 1.4;
}
.notification-item-copy span {
color: #52606d;
font-size: 11px;
line-height: 1.45;
margin-top: 3px;
overflow-wrap: anywhere;
}
.notification-item-copy time {
color: #84909a;
font-size: 10px;
margin-top: 5px;
}
.notification-unread-dot {
align-self: center;
background: var(--primary);
border-radius: 50%;
height: 7px;
opacity: 0;
width: 7px;
}
.notification-item.unread .notification-unread-dot {
opacity: 1;
}
.notification-empty {
align-items: center;
color: #64748b;
display: flex;
flex-direction: column;
gap: 5px;
min-height: 180px;
justify-content: center;
padding: 24px;
text-align: center;
}
.notification-empty .material-symbols-outlined {
color: #94a3b8;
font-size: 32px;
}
.notification-empty strong {
color: #334155;
font-size: 12px;
}
.notification-empty span:last-child {
font-size: 11px;
}
.notification-panel-footer {
background: #f8fafc;
border-top: 1px solid #eef2f7;
padding: 10px 14px;
}
.notification-panel-footer .btn {
width: 100%;
}
.profile-chip { .profile-chip {
align-items: center; align-items: center;
background: transparent; background: transparent;
@@ -419,6 +671,7 @@ button:disabled {
color: var(--danger); color: var(--danger);
} }
.dashboard-stats { .dashboard-stats {
display: grid; display: grid;
flex-shrink: 0; flex-shrink: 0;
@@ -1060,6 +1313,13 @@ tbody tr:hover td.action-col {
font-weight: 800; font-weight: 800;
} }
.modal-header p {
color: #64748b;
font-size: 11px;
line-height: 1.45;
margin-top: 3px;
}
.modal-form { .modal-form {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -1464,6 +1724,15 @@ tbody tr:hover td.action-col {
padding: 0 14px; padding: 0 14px;
} }
.notification-panel {
left: 8px;
max-height: calc(100dvh - 72px);
position: fixed;
right: 8px;
top: 64px;
width: auto;
}
.profile-meta { .profile-meta {
display: none; display: none;
} }

View File

@@ -596,11 +596,343 @@
}); });
} }
function formatNotificationTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
if (elapsedSeconds < 60) return 'Vừa xong';
if (elapsedSeconds < 3600) return `${Math.floor(elapsedSeconds / 60)} phút trước`;
if (elapsedSeconds < 86400) return `${Math.floor(elapsedSeconds / 3600)} giờ trước`;
if (elapsedSeconds < 604800) return `${Math.floor(elapsedSeconds / 86400)} ngày trước`;
return new Intl.DateTimeFormat('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
}
function getNotificationIcon(notification) {
if (notification.isSystem) return 'campaign';
if (notification.severity === 'success') return 'check_circle';
if (notification.severity === 'warning') return 'warning';
if (notification.severity === 'error') return 'error';
if (notification.entityType === 'package') return 'inventory_2';
if (notification.entityType === 'application') return 'apps';
return 'notifications';
}
async function notificationRequest(url, options = {}) {
const response = await fetch(url, {
cache: 'no-store',
credentials: 'same-origin',
...options,
headers: {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {})
}
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || 'Không thể xử lý thông báo.');
}
return data;
}
function initNotificationCenter() {
const center = document.querySelector('[data-notification-center]');
if (!center) return;
const toggle = center.querySelector('[data-notification-toggle]');
const panel = center.querySelector('[data-notification-panel]');
const badge = center.querySelector('[data-notification-badge]');
const summary = center.querySelector('[data-notification-summary]');
const list = center.querySelector('[data-notification-list]');
const empty = center.querySelector('[data-notification-empty]');
const readAllButton = center.querySelector('[data-notification-read-all]');
const filterButtons = center.querySelectorAll('[data-notification-filter]');
const knownNotificationIds = new Set();
let currentFilter = 'all';
let initialized = false;
let loading = false;
let reloadPending = false;
function setPanelOpen(open) {
panel.hidden = !open;
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function setEmptyState(title, message, visible) {
const titleElement = empty.querySelector('strong');
const messageElement = empty.querySelector('span:last-child');
if (titleElement) titleElement.textContent = title;
if (messageElement) messageElement.textContent = message;
empty.hidden = !visible;
}
function updateUnreadCount(count) {
const unreadCount = Math.max(0, Number(count) || 0);
badge.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
badge.hidden = unreadCount === 0;
summary.textContent = unreadCount > 0 ? `${unreadCount} thông báo chưa đọc` : 'Không có thông báo chưa đọc';
readAllButton.disabled = unreadCount === 0;
}
function renderNotifications(notifications) {
list.replaceChildren();
setEmptyState(
currentFilter === 'unread' ? 'Không có thông báo chưa đọc' : 'Chưa có thông báo',
'Các cập nhật quan trọng sẽ xuất hiện tại đây.',
notifications.length === 0
);
notifications.forEach((notification) => {
const item = document.createElement('button');
item.type = 'button';
item.className = `notification-item tone-${notification.severity || 'info'}${notification.isRead ? '' : ' unread'}`;
item.dataset.notificationId = notification.id;
const icon = document.createElement('span');
icon.className = 'notification-item-icon';
const iconGlyph = document.createElement('span');
iconGlyph.className = 'material-symbols-outlined';
iconGlyph.setAttribute('aria-hidden', 'true');
iconGlyph.textContent = getNotificationIcon(notification);
icon.appendChild(iconGlyph);
const copy = document.createElement('span');
copy.className = 'notification-item-copy';
const title = document.createElement('strong');
title.textContent = notification.title;
const message = document.createElement('span');
message.textContent = notification.message;
const time = document.createElement('time');
time.dateTime = notification.createdAt || '';
time.textContent = formatNotificationTime(notification.createdAt);
copy.append(title, message, time);
const unreadDot = document.createElement('span');
unreadDot.className = 'notification-unread-dot';
unreadDot.setAttribute('aria-label', notification.isRead ? 'Đã đọc' : 'Chưa đọc');
item.append(icon, copy, unreadDot);
item.addEventListener('click', async () => {
try {
if (!notification.isRead) {
await notificationRequest(`/api/notifications/${encodeURIComponent(notification.id)}/read`, {
method: 'POST'
});
notification.isRead = true;
item.classList.remove('unread');
await loadNotifications(false);
}
} catch (error) {
notify('warning', error.message);
}
if (
notification.actionUrl
&& notification.actionUrl.startsWith('/')
&& !notification.actionUrl.startsWith('//')
) {
window.location.assign(notification.actionUrl);
}
});
list.appendChild(item);
});
}
async function loadNotifications(announceNew = true) {
if (loading) {
reloadPending = true;
return;
}
loading = true;
try {
const data = await notificationRequest(
`/api/notifications?limit=20&unreadOnly=${currentFilter === 'unread' ? 'true' : 'false'}`
);
if (!data.available) {
badge.hidden = true;
summary.textContent = 'Chưa kích hoạt trong database';
readAllButton.disabled = true;
list.replaceChildren();
setEmptyState(
'Thông báo chưa khả dụng',
'Admin cần chạy migration 04_notifications.sql.',
true
);
return;
}
const notifications = Array.isArray(data.notifications) ? data.notifications : [];
if (initialized && announceNew) {
const importantNotification = notifications.find((notification) => (
!knownNotificationIds.has(notification.id)
&& !notification.isRead
&& ['warning', 'error'].includes(notification.severity)
));
if (importantNotification) {
notify(
importantNotification.severity === 'error' ? 'failure' : 'warning',
importantNotification.title
);
}
}
notifications.forEach((notification) => knownNotificationIds.add(notification.id));
initialized = true;
updateUnreadCount(data.unreadCount);
renderNotifications(notifications);
} catch (error) {
summary.textContent = 'Không thể tải thông báo';
setEmptyState('Không thể tải thông báo', 'Vui lòng thử lại sau.', true);
} finally {
loading = false;
if (reloadPending) {
reloadPending = false;
loadNotifications(false);
}
}
}
async function refreshUnreadCount() {
try {
const data = await notificationRequest('/api/notifications?countOnly=true');
if (!data.available) {
badge.hidden = true;
summary.textContent = 'Chưa kích hoạt trong database';
readAllButton.disabled = true;
return;
}
updateUnreadCount(data.unreadCount);
} catch (error) {
console.info('Cannot refresh notification count:', error);
}
}
toggle.addEventListener('click', () => {
const open = panel.hidden;
setPanelOpen(open);
if (open) loadNotifications(false);
});
readAllButton.addEventListener('click', async () => {
readAllButton.disabled = true;
try {
await notificationRequest('/api/notifications/read-all', { method: 'POST' });
await loadNotifications(false);
} catch (error) {
notify('failure', error.message);
}
});
filterButtons.forEach((button) => {
button.addEventListener('click', () => {
currentFilter = button.dataset.notificationFilter;
filterButtons.forEach((candidate) => {
const active = candidate === button;
candidate.classList.toggle('active', active);
candidate.setAttribute('aria-selected', active ? 'true' : 'false');
});
loadNotifications(false);
});
});
document.addEventListener('click', (event) => {
if (!panel.hidden && !center.contains(event.target)) {
setPanelOpen(false);
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !panel.hidden) {
setPanelOpen(false);
toggle.focus();
}
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (panel.hidden) refreshUnreadCount();
else loadNotifications(true);
}
});
document.addEventListener('notifications:refresh', () => {
if (panel.hidden) refreshUnreadCount();
else loadNotifications(false);
});
const broadcastButton = center.querySelector('[data-modal-open="systemNotificationModal"]');
if (broadcastButton) {
broadcastButton.addEventListener('click', () => setPanelOpen(false));
}
window.setInterval(() => {
if (document.visibilityState !== 'visible') return;
if (panel.hidden) refreshUnreadCount();
else loadNotifications(true);
}, 30000);
refreshUnreadCount();
}
function initSystemNotificationForm() {
const form = document.querySelector('[data-system-notification-form]');
if (!form) return;
form.addEventListener('submit', (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
confirmAction('Gửi thông báo này tới tất cả tài khoản đang hoạt động?', async () => {
const submitButton = form.querySelector('[data-system-notification-submit]');
const formData = new FormData(form);
submitButton.disabled = true;
try {
const result = await notificationRequest('/api/notifications/broadcasts', {
method: 'POST',
body: JSON.stringify({
title: formData.get('title'),
severity: formData.get('severity'),
message: formData.get('message'),
actionUrl: formData.get('actionUrl')
})
});
closeModal(form.closest('.modal-backdrop'));
form.reset();
notify('success', `Đã gửi thông báo tới ${result.recipientCount || 0} người dùng.`);
document.dispatchEvent(new CustomEvent('notifications:refresh'));
} catch (error) {
notify('failure', error.message);
} finally {
submitButton.disabled = false;
}
});
});
}
initNotiflix(); initNotiflix();
initFileDropzones(); initFileDropzones();
initRegistrationUniqueChecks(); initRegistrationUniqueChecks();
initProfileForms(); initProfileForms();
initEditAppForms(); initEditAppForms();
initNotificationCenter();
initSystemNotificationForm();
if (body.dataset.notice) { if (body.dataset.notice) {
notify(body.dataset.noticeType || 'info', body.dataset.notice); notify(body.dataset.noticeType || 'info', body.dataset.notice);

View File

@@ -8,6 +8,7 @@ const path = require('path');
const express = require('express'); const express = require('express');
const multer = require('multer'); const multer = require('multer');
const repository = require('./src/repository'); const repository = require('./src/repository');
const notificationRepository = require('./src/notification-repository');
const mailer = require('./src/mailer'); const mailer = require('./src/mailer');
const { closePool, getPool } = require('./src/db'); const { closePool, getPool } = require('./src/db');
const notiflixVersion = require('notiflix/package.json').version; const notiflixVersion = require('notiflix/package.json').version;
@@ -123,6 +124,7 @@ app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json({ limit: '32kb' }));
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist'))); app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
app.use(applyPublicApiCors); app.use(applyPublicApiCors);
@@ -524,6 +526,55 @@ function sanitizeReturnTo(value) {
return value; return value;
} }
function sanitizeNotificationActionUrl(value) {
const text = String(value || '').trim();
if (!text) return '';
if (
text.length > 1000
|| !text.startsWith('/')
|| text.startsWith('//')
|| text.includes('\\')
|| /[\u0000-\u001F\u007F]/.test(text)
) {
return null;
}
return text;
}
function normalizeNotificationSeverity(value) {
const severity = String(value || 'info').trim().toLowerCase();
return ['info', 'success', 'warning', 'error'].includes(severity) ? severity : null;
}
function isUuid(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
String(value || '').trim()
);
}
function getNotificationActorName(user) {
return String(user?.name || user?.username || '').trim();
}
async function safelyCreateNotification(label, callback) {
try {
return await callback();
} catch (error) {
console.error(`Cannot create notification (${label}):`, error);
return null;
}
}
async function safelyLoadNotificationContext(label, callback) {
try {
return await callback();
} catch (error) {
console.error(`Cannot load notification context (${label}):`, error);
return null;
}
}
function getBaseUrl(req) { function getBaseUrl(req) {
const requestBaseUrl = getRequestBaseUrl(req); const requestBaseUrl = getRequestBaseUrl(req);
if (process.env.APP_BASE_URL) { if (process.env.APP_BASE_URL) {
@@ -851,6 +902,15 @@ function requireAdmin(req, res, next) {
redirectWithNotice(res, '/', 'failure', 'Bạn cần quyền Admin để quản lý user.'); redirectWithNotice(res, '/', 'failure', 'Bạn cần quyền Admin để quản lý user.');
} }
function requireAdminApi(req, res, next) {
if (req.currentUser && req.currentUser.role === 'Admin') {
next();
return;
}
res.status(403).json({ error: 'Bạn cần quyền Admin để thực hiện thao tác này.' });
}
function normalizePackageType(value) { function normalizePackageType(value) {
const packageType = String(value || 'deb').toLowerCase(); const packageType = String(value || 'deb').toLowerCase();
return ['deb', 'apt', 'docker'].includes(packageType) ? packageType : 'deb'; return ['deb', 'apt', 'docker'].includes(packageType) ? packageType : 'deb';
@@ -1497,6 +1557,92 @@ exit 1
app.use(requireAuthenticated); app.use(requireAuthenticated);
app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.get('/api/notifications', asyncRoute(async (req, res) => {
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
const result = countOnly
? await notificationRepository.getUnreadCount(req.currentUser.id)
: await notificationRepository.listNotifications(req.currentUser.id, {
limit: req.query.limit,
unreadOnly: String(req.query.unreadOnly || '').toLowerCase() === 'true'
});
res.setHeader('Cache-Control', 'no-store');
res.json(result);
}));
app.post('/api/notifications/read-all', asyncRoute(async (req, res) => {
const result = await notificationRepository.markAllNotificationsRead(req.currentUser.id);
if (!result.available) {
res.status(503).json({ error: 'Tính năng thông báo chưa được kích hoạt trong database.' });
return;
}
res.json(result);
}));
app.post('/api/notifications/:id/read', asyncRoute(async (req, res) => {
if (!isUuid(req.params.id)) {
res.status(400).json({ error: 'Notification id không hợp lệ.' });
return;
}
const result = await notificationRepository.markNotificationRead(req.currentUser.id, req.params.id);
if (!result.available) {
res.status(503).json({ error: 'Tính năng thông báo chưa được kích hoạt trong database.' });
return;
}
if (!result.updated) {
res.status(404).json({ error: 'Không tìm thấy thông báo.' });
return;
}
res.json(result);
}));
app.post('/api/notifications/broadcasts', requireAdminApi, asyncRoute(async (req, res) => {
const title = String(req.body.title || '').trim();
const message = String(req.body.message || '').trim();
const severity = normalizeNotificationSeverity(req.body.severity);
const actionUrl = sanitizeNotificationActionUrl(req.body.actionUrl);
if (!title || title.length > 200) {
res.status(400).json({ error: 'Tiêu đề bắt buộc và không được vượt quá 200 ký tự.' });
return;
}
if (!message || message.length > 1000) {
res.status(400).json({ error: 'Nội dung bắt buộc và không được vượt quá 1000 ký tự.' });
return;
}
if (!severity) {
res.status(400).json({ error: 'Mức độ thông báo không hợp lệ.' });
return;
}
if (actionUrl === null) {
res.status(400).json({ error: 'Đường dẫn phải là đường dẫn nội bộ bắt đầu bằng /.' });
return;
}
const result = await notificationRepository.createSystemBroadcast({
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser),
severity,
title,
message,
actionUrl
});
if (!result.available) {
res.status(503).json({ error: 'Chưa có bảng dbo.Notifications. Hãy chạy migration 04_notifications.sql trước.' });
return;
}
res.status(201).json(result);
}));
app.get('/agent', requireAdmin, asyncRoute(async (req, res) => { app.get('/agent', requireAdmin, asyncRoute(async (req, res) => {
const pageData = await repository.getPageData(req.currentUser); const pageData = await repository.getPageData(req.currentUser);
const agentPackages = await listAgentPackages(); const agentPackages = await listAgentPackages();
@@ -1824,6 +1970,14 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
}; };
await repository.addPackageVersion(versionInput); await repository.addPackageVersion(versionInput);
await safelyCreateNotification('package version available', () => (
notificationRepository.notifyPackageVersionAvailable({
packageId: versionInput.packageId,
version: versionInput.version,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser)
})
));
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.'); redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.');
} catch (error) { } catch (error) {
@@ -1833,6 +1987,15 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput); const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput);
if (replacedVersionId) { if (replacedVersionId) {
await safelyCreateNotification('package version artifact updated', () => (
notificationRepository.notifyPackageVersionAvailable({
packageId: versionInput.packageId,
version: versionInput.version,
isArtifactUpdate: true,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser)
})
));
redirectWithNotice( redirectWithNotice(
res, res,
`/packages/${versionInput.packageId}`, `/packages/${versionInput.packageId}`,
@@ -1857,8 +2020,28 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
})); }));
app.post('/packages/:id/delete', asyncRoute(async (req, res) => { app.post('/packages/:id/delete', asyncRoute(async (req, res) => {
const notificationImpact = await safelyLoadNotificationContext('package delete', () => (
notificationRepository.getPackageImpact(req.params.id)
));
const deleted = await repository.deletePackage(req.params.id); const deleted = await repository.deletePackage(req.params.id);
if (deleted && notificationImpact) {
await safelyCreateNotification('package deleted', () => (
notificationRepository.createNotificationsForUsers({
recipientUserIds: notificationImpact.recipientUserIds,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser),
eventType: 'package.deleted',
severity: 'error',
title: `Package ${notificationImpact.packageCode} đã bị xóa`,
message: 'Các liên kết application sử dụng package này đã bị gỡ. Hãy kiểm tra lại cấu hình application liên quan.',
actionUrl: '/applications',
entityType: 'package',
entityId: notificationImpact.packageId
})
));
}
redirectWithNotice( redirectWithNotice(
res, res,
'/packages', '/packages',
@@ -1876,8 +2059,28 @@ app.post('/package-versions/:id/latest', asyncRoute(async (req, res) => {
app.post('/package-versions/:id/delete', asyncRoute(async (req, res) => { app.post('/package-versions/:id/delete', asyncRoute(async (req, res) => {
const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages'); const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages');
const notificationImpact = await safelyLoadNotificationContext('package version delete', () => (
notificationRepository.getPackageVersionImpact(req.params.id)
));
const result = await repository.deletePackageVersion(req.params.id); const result = await repository.deletePackageVersion(req.params.id);
if (result.deleted && notificationImpact) {
await safelyCreateNotification('package version deleted', () => (
notificationRepository.createNotificationsForUsers({
recipientUserIds: notificationImpact.recipientUserIds,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser),
eventType: 'package.version_deleted',
severity: 'warning',
title: `Version ${notificationImpact.version} của ${notificationImpact.packageCode} đã bị xóa`,
message: 'Các liên kết application sử dụng chính version này đã bị gỡ. Hãy kiểm tra lại application liên quan.',
actionUrl: '/applications',
entityType: 'package',
entityId: notificationImpact.packageId
})
));
}
redirectWithNotice( redirectWithNotice(
res, res,
result.packageId ? `/packages/${result.packageId}` : returnTo, result.packageId ? `/packages/${result.packageId}` : returnTo,
@@ -2037,6 +2240,16 @@ app.post('/applications/:id/release', asyncRoute(async (req, res) => {
const returnTo = sanitizeReturnTo(req.body.returnTo || `/applications/${applicationId}`); const returnTo = sanitizeReturnTo(req.body.returnTo || `/applications/${applicationId}`);
const updated = await repository.updateApplicationStatus(applicationId, 'Released'); const updated = await repository.updateApplicationStatus(applicationId, 'Released');
if (updated) {
await safelyCreateNotification('application released', () => (
notificationRepository.notifyApplicationReleased({
applicationId,
actorUserId: req.currentUser.id,
actorName: getNotificationActorName(req.currentUser)
})
));
}
redirectWithNotice( redirectWithNotice(
res, res,
returnTo, returnTo,
@@ -2237,6 +2450,13 @@ app.get('/builder', asyncRoute(async (req, res) => {
app.use(async (error, req, res, next) => { app.use(async (error, req, res, next) => {
console.error(error); console.error(error);
if (req.path.startsWith('/api/notifications')) {
res.status(500).json({
error: 'Không thể xử lý thông báo lúc này. Vui lòng thử lại sau.'
});
return;
}
const pageData = await repository.getPageData(req.currentUser).catch(() => ({ const pageData = await repository.getPageData(req.currentUser).catch(() => ({
currentUser: req.currentUser || { name: 'Guest', role: 'Guest', email: '' }, currentUser: req.currentUser || { name: 'Guest', role: 'Guest', email: '' },
stats: { stats: {

View File

@@ -0,0 +1,458 @@
const crypto = require('crypto');
const { sql, getPool } = require('./db');
const SCHEMA_CHECK_TTL_MS = 30 * 1000;
const ALLOWED_SEVERITIES = new Set(['info', 'success', 'warning', 'error']);
let notificationSchemaState = {
available: false,
checkedAt: 0
};
function normalizeSeverity(value) {
const severity = String(value || '').trim().toLowerCase();
return ALLOWED_SEVERITIES.has(severity) ? severity : 'info';
}
function toIsoString(value) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function mapNotificationRow(row) {
return {
id: String(row.Id),
eventType: row.EventType,
isSystem: row.EventType === 'system.announcement',
severity: normalizeSeverity(row.Severity),
title: row.Title,
message: row.Message,
entityType: row.EntityType || '',
entityId: row.EntityId || '',
actionUrl: row.ActionUrl || '',
actorName: row.ActorName || '',
isRead: Boolean(row.ReadAt),
readAt: toIsoString(row.ReadAt),
createdAt: toIsoString(row.CreatedAt)
};
}
async function checkNotificationSchema(force = false) {
const now = Date.now();
if (!force && now - notificationSchemaState.checkedAt < SCHEMA_CHECK_TTL_MS) {
return notificationSchemaState.available;
}
const pool = await getPool();
const result = await pool.request().query(`
SELECT CASE WHEN OBJECT_ID(N'dbo.Notifications', N'U') IS NULL THEN 0 ELSE 1 END AS IsAvailable;
`);
notificationSchemaState = {
available: Boolean(result.recordset[0] && result.recordset[0].IsAvailable),
checkedAt: now
};
return notificationSchemaState.available;
}
async function listNotifications(userId, options = {}) {
const available = await checkNotificationSchema();
if (!available) {
return {
available: false,
notifications: [],
unreadCount: 0
};
}
const limit = Math.min(Math.max(Math.trunc(Number(options.limit)) || 20, 1), 50);
const unreadOnly = Boolean(options.unreadOnly);
const pool = await getPool();
const result = await pool.request()
.input('RecipientUserId', sql.UniqueIdentifier, userId)
.input('Limit', sql.Int, limit)
.input('UnreadOnly', sql.Bit, unreadOnly ? 1 : 0)
.query(`
SELECT COUNT_BIG(*) AS UnreadCount
FROM dbo.Notifications
WHERE RecipientUserId = @RecipientUserId
AND ReadAt IS NULL
AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME());
SELECT TOP (@Limit)
Id, EventType, Severity, Title, Message, EntityType, EntityId,
ActionUrl, ActorName, ReadAt, CreatedAt
FROM dbo.Notifications
WHERE RecipientUserId = @RecipientUserId
AND (@UnreadOnly = 0 OR ReadAt IS NULL)
AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME())
ORDER BY CreatedAt DESC, Id DESC;
`);
return {
available: true,
unreadCount: Number(result.recordsets[0][0].UnreadCount || 0),
notifications: result.recordsets[1].map(mapNotificationRow)
};
}
async function getUnreadCount(userId) {
const available = await checkNotificationSchema();
if (!available) {
return { available: false, unreadCount: 0 };
}
const pool = await getPool();
const result = await pool.request()
.input('RecipientUserId', sql.UniqueIdentifier, userId)
.query(`
SELECT COUNT_BIG(*) AS UnreadCount
FROM dbo.Notifications
WHERE RecipientUserId = @RecipientUserId
AND ReadAt IS NULL
AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME());
`);
return {
available: true,
unreadCount: Number(result.recordset[0].UnreadCount || 0)
};
}
async function markNotificationRead(notificationId, userId) {
if (!await checkNotificationSchema()) {
return { available: false, updated: false };
}
const pool = await getPool();
const result = await pool.request()
.input('Id', sql.UniqueIdentifier, notificationId)
.input('RecipientUserId', sql.UniqueIdentifier, userId)
.query(`
UPDATE dbo.Notifications
SET ReadAt = COALESCE(ReadAt, SYSUTCDATETIME())
OUTPUT inserted.Id
WHERE Id = @Id
AND RecipientUserId = @RecipientUserId;
`);
return {
available: true,
updated: result.recordset.length > 0
};
}
async function markAllNotificationsRead(userId) {
if (!await checkNotificationSchema()) {
return { available: false, updatedCount: 0 };
}
const pool = await getPool();
const result = await pool.request()
.input('RecipientUserId', sql.UniqueIdentifier, userId)
.query(`
UPDATE dbo.Notifications
SET ReadAt = SYSUTCDATETIME()
OUTPUT inserted.Id
WHERE RecipientUserId = @RecipientUserId
AND ReadAt IS NULL
AND (ExpiresAt IS NULL OR ExpiresAt > SYSUTCDATETIME());
`);
return {
available: true,
updatedCount: result.recordset.length
};
}
async function createSystemBroadcast(input) {
if (!await checkNotificationSchema()) {
return { available: false, broadcastId: null, recipientCount: 0 };
}
const pool = await getPool();
const broadcastId = crypto.randomUUID();
const result = await pool.request()
.input('ActorUserId', sql.UniqueIdentifier, input.actorUserId)
.input('ActorName', sql.NVarChar(200), input.actorName || null)
.input('BroadcastId', sql.UniqueIdentifier, broadcastId)
.input('Severity', sql.NVarChar(20), normalizeSeverity(input.severity))
.input('Title', sql.NVarChar(200), input.title)
.input('Message', sql.NVarChar(1000), input.message)
.input('ActionUrl', sql.NVarChar(1000), input.actionUrl || null)
.query(`
INSERT dbo.Notifications (
RecipientUserId, ActorUserId, ActorName, BroadcastId, EventType,
Severity, Title, Message, EntityType, ActionUrl
)
SELECT
u.Id, @ActorUserId, @ActorName, @BroadcastId, N'system.announcement',
@Severity, @Title, @Message, N'system', @ActionUrl
FROM dbo.Users AS u
WHERE u.IsActive = 1;
DECLARE @RecipientCount INT = @@ROWCOUNT;
SELECT @RecipientCount AS RecipientCount;
`);
return {
available: true,
broadcastId,
recipientCount: Number(result.recordset[0].RecipientCount || 0)
};
}
async function createNotificationsForUsers(input) {
if (!await checkNotificationSchema()) {
return { available: false, recipientCount: 0 };
}
const uniqueRecipientIds = Array.from(new Set((input.recipientUserIds || []).filter(Boolean))).slice(0, 1000);
if (uniqueRecipientIds.length === 0) {
return { available: true, recipientCount: 0 };
}
const pool = await getPool();
const request = pool.request()
.input('ActorUserId', sql.UniqueIdentifier, input.actorUserId || null)
.input('ActorName', sql.NVarChar(200), input.actorName || null)
.input('EventType', sql.NVarChar(100), input.eventType)
.input('Severity', sql.NVarChar(20), normalizeSeverity(input.severity))
.input('Title', sql.NVarChar(200), input.title)
.input('Message', sql.NVarChar(1000), input.message)
.input('EntityType', sql.NVarChar(50), input.entityType || null)
.input('EntityId', sql.NVarChar(100), input.entityId || null)
.input('ActionUrl', sql.NVarChar(1000), input.actionUrl || null);
const recipientRows = uniqueRecipientIds.map((userId, index) => {
const parameterName = `RecipientUserId${index}`;
request.input(parameterName, sql.UniqueIdentifier, userId);
return `SELECT @${parameterName} AS Id`;
}).join('\nUNION ALL\n');
const result = await request.query(`
WITH RequestedRecipients AS (
${recipientRows}
)
INSERT dbo.Notifications (
RecipientUserId, ActorUserId, ActorName, EventType, Severity,
Title, Message, EntityType, EntityId, ActionUrl
)
SELECT DISTINCT
u.Id, @ActorUserId, @ActorName, @EventType, @Severity,
@Title, @Message, @EntityType, @EntityId, @ActionUrl
FROM dbo.Users AS u
INNER JOIN RequestedRecipients AS requested ON requested.Id = u.Id
WHERE u.IsActive = 1
AND (@ActorUserId IS NULL OR u.Id <> @ActorUserId);
DECLARE @RecipientCount INT = @@ROWCOUNT;
SELECT @RecipientCount AS RecipientCount;
`);
return {
available: true,
recipientCount: Number(result.recordset[0].RecipientCount || 0)
};
}
async function getPackageVersionAudience(packageId) {
if (!await checkNotificationSchema()) return [];
const pool = await getPool();
const result = await pool.request()
.input('PackageId', sql.UniqueIdentifier, packageId)
.query(`
SELECT DISTINCT a.CreatedByUserId AS UserId
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Applications AS a ON a.Id = ap.ApplicationId
WHERE ap.PackageId = @PackageId;
`);
return result.recordset.map((row) => String(row.UserId));
}
async function notifyPackageVersionAvailable(input) {
if (!await checkNotificationSchema()) {
return { available: false, recipientCount: 0 };
}
const pool = await getPool();
const packageResult = await pool.request()
.input('PackageId', sql.UniqueIdentifier, input.packageId)
.query(`
SELECT TOP (1) Id, PackageCode, PackageName
FROM dbo.Packages
WHERE Id = @PackageId;
`);
const packageRow = packageResult.recordset[0];
if (!packageRow) {
return { available: true, recipientCount: 0 };
}
const recipientUserIds = await getPackageVersionAudience(input.packageId);
const isArtifactUpdate = Boolean(input.isArtifactUpdate);
return createNotificationsForUsers({
recipientUserIds,
actorUserId: input.actorUserId,
actorName: input.actorName,
eventType: isArtifactUpdate ? 'package.version_artifact_updated' : 'package.version_available',
severity: 'info',
title: isArtifactUpdate
? `Artifact ${packageRow.PackageCode} ${input.version} đã được cập nhật`
: `Package ${packageRow.PackageCode} có version ${input.version} mới`,
message: isArtifactUpdate
? 'Artifact của version hiện tại đã được upload lại. Hãy kiểm tra trước khi triển khai tiếp.'
: 'Application của bạn đang sử dụng package này. Hãy kiểm tra và chọn version mới khi phù hợp.',
entityType: 'package',
entityId: String(packageRow.Id),
actionUrl: `/packages/${packageRow.Id}`
});
}
async function getPackageDeletionContext(packageId) {
if (!await checkNotificationSchema()) return null;
const pool = await getPool();
const result = await pool.request()
.input('PackageId', sql.UniqueIdentifier, packageId)
.query(`
SELECT TOP (1) Id, PackageCode, PackageName, CreatedByUserId
FROM dbo.Packages
WHERE Id = @PackageId;
SELECT DISTINCT recipients.UserId
FROM (
SELECT a.CreatedByUserId AS UserId
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Applications AS a ON a.Id = ap.ApplicationId
WHERE ap.PackageId = @PackageId
UNION
SELECT CreatedByUserId FROM dbo.Packages WHERE Id = @PackageId
UNION
SELECT Id FROM dbo.Users WHERE Role = N'Admin'
) AS recipients;
`);
const packageRow = result.recordsets[0][0];
if (!packageRow) return null;
return {
packageId: String(packageRow.Id),
packageCode: packageRow.PackageCode,
packageName: packageRow.PackageName,
recipientUserIds: result.recordsets[1].map((row) => String(row.UserId))
};
}
async function getPackageVersionDeletionContext(packageVersionId) {
if (!await checkNotificationSchema()) return null;
const pool = await getPool();
const result = await pool.request()
.input('PackageVersionId', sql.UniqueIdentifier, packageVersionId)
.query(`
SELECT TOP (1)
pv.Id, pv.PackageId, pv.Version, p.PackageCode, p.PackageName, p.CreatedByUserId
FROM dbo.PackageVersions AS pv
INNER JOIN dbo.Packages AS p ON p.Id = pv.PackageId
WHERE pv.Id = @PackageVersionId;
SELECT DISTINCT recipients.UserId
FROM (
SELECT a.CreatedByUserId AS UserId
FROM dbo.ApplicationPackages AS ap
INNER JOIN dbo.Applications AS a ON a.Id = ap.ApplicationId
WHERE ap.SelectedVersionId = @PackageVersionId
UNION
SELECT p.CreatedByUserId
FROM dbo.PackageVersions AS pv
INNER JOIN dbo.Packages AS p ON p.Id = pv.PackageId
WHERE pv.Id = @PackageVersionId
UNION
SELECT Id FROM dbo.Users WHERE Role = N'Admin'
) AS recipients;
`);
const versionRow = result.recordsets[0][0];
if (!versionRow) return null;
return {
packageVersionId: String(versionRow.Id),
packageId: String(versionRow.PackageId),
packageCode: versionRow.PackageCode,
packageName: versionRow.PackageName,
version: versionRow.Version,
recipientUserIds: result.recordsets[1].map((row) => String(row.UserId))
};
}
async function getApplicationReleaseContext(applicationId) {
if (!await checkNotificationSchema()) return null;
const pool = await getPool();
const result = await pool.request()
.input('ApplicationId', sql.UniqueIdentifier, applicationId)
.query(`
SELECT TOP (1) Id, AppCode, AppName, AppVersion, CreatedByUserId
FROM dbo.Applications
WHERE Id = @ApplicationId;
SELECT DISTINCT recipients.UserId
FROM (
SELECT CreatedByUserId AS UserId
FROM dbo.Applications
WHERE Id = @ApplicationId
UNION
SELECT Id FROM dbo.Users WHERE Role = N'Admin'
) AS recipients;
`);
const applicationRow = result.recordsets[0][0];
if (!applicationRow) return null;
return {
applicationId: String(applicationRow.Id),
appCode: applicationRow.AppCode,
appName: applicationRow.AppName,
appVersion: applicationRow.AppVersion,
recipientUserIds: result.recordsets[1].map((row) => String(row.UserId))
};
}
async function notifyApplicationReleased(input) {
const context = await getApplicationReleaseContext(input.applicationId);
if (!context) {
return {
available: await checkNotificationSchema(),
recipientCount: 0
};
}
return createNotificationsForUsers({
recipientUserIds: context.recipientUserIds,
actorUserId: input.actorUserId,
actorName: input.actorName,
eventType: 'application.released',
severity: 'success',
title: `Application ${context.appCode} đã được release`,
message: `Version ${context.appVersion} đã chuyển sang trạng thái Released.`,
entityType: 'application',
entityId: context.applicationId,
actionUrl: `/applications/${context.applicationId}`
});
}
module.exports = {
checkNotificationSchema,
createNotificationsForUsers,
createSystemBroadcast,
getApplicationReleaseContext,
getPackageImpact: getPackageDeletionContext,
getPackageDeletionContext,
getPackageVersionAudience,
getPackageVersionImpact: getPackageVersionDeletionContext,
getPackageVersionDeletionContext,
getUnreadCount,
listNotifications,
markAllNotificationsRead,
markNotificationRead: (userId, notificationId) => markNotificationRead(notificationId, userId),
notifyApplicationReleased,
notifyPackageVersionAvailable
};

View File

@@ -55,6 +55,56 @@
</div> </div>
<% } %> <% } %>
<% if (currentUser && currentUser.role === 'Admin') { %>
<div id="systemNotificationModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="systemNotificationModalTitle">
<div class="modal-content">
<div class="modal-header">
<div>
<h3 id="systemNotificationModalTitle">Đăng thông báo hệ thống</h3>
<p>Thông báo sẽ được gửi tới tất cả tài khoản đang hoạt động.</p>
</div>
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form class="modal-form" data-system-notification-form>
<div class="form-stack">
<label class="form-field">
<span>Tiêu đề</span>
<input type="text" name="title" maxlength="200" required placeholder="Ví dụ: Hệ thống đã cập nhật phiên bản 1.2.0">
</label>
<label class="form-field">
<span>Mức độ</span>
<select name="severity" required>
<option value="info">Thông tin</option>
<option value="success">Cập nhật thành công</option>
<option value="warning">Quan trọng / bảo trì</option>
<option value="error">Khẩn cấp / sự cố</option>
</select>
</label>
<label class="form-field">
<span>Nội dung</span>
<textarea name="message" rows="6" maxlength="1000" required placeholder="Mô tả nội dung cập nhật, ảnh hưởng và việc người dùng cần thực hiện."></textarea>
<small>Tối đa 1000 ký tự. Không nhập mật khẩu, token hoặc thông tin nhạy cảm.</small>
</label>
<label class="form-field">
<span>Đường dẫn nội bộ (không bắt buộc)</span>
<input type="text" name="actionUrl" maxlength="1000" placeholder="Ví dụ: /applications hoặc /agent">
<small>Chỉ chấp nhận đường dẫn trong website bắt đầu bằng dấu /.</small>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
<button class="btn btn-primary" type="submit" data-system-notification-submit>
<span class="material-symbols-outlined">send</span>
Gửi cho mọi người
</button>
</div>
</form>
</div>
</div>
<% } %>
<script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script> <script src="/vendor/notiflix/notiflix-<%= notiflixVersion %>.min.js"></script>
<script src="/js/app.js"></script> <script src="/js/app.js"></script>
</body> </body>

View File

@@ -52,9 +52,52 @@
<button class="icon-button" type="button" title="Đồng bộ dữ liệu" data-refresh-page> <button class="icon-button" type="button" title="Đồng bộ dữ liệu" data-refresh-page>
<span class="material-symbols-outlined">sync</span> <span class="material-symbols-outlined">sync</span>
</button> </button>
<button class="icon-button" type="button" title="Thông báo" data-toast="Chưa có thông báo mới"> <div class="notification-center" data-notification-center>
<span class="material-symbols-outlined">notifications</span> <button
</button> id="notificationButton"
class="icon-button notification-button"
type="button"
title="Thông báo"
aria-label="Mở thông báo"
aria-controls="notificationPanel"
aria-expanded="false"
data-notification-toggle
>
<span class="material-symbols-outlined">notifications</span>
<span class="notification-badge" data-notification-badge hidden>0</span>
</button>
<section id="notificationPanel" class="notification-panel" aria-label="Thông báo" hidden data-notification-panel>
<div class="notification-panel-header">
<div>
<strong>Thông báo</strong>
<span data-notification-summary>Đang tải...</span>
</div>
<button class="text-button" type="button" data-notification-read-all>Đánh dấu đã đọc</button>
</div>
<div class="notification-tabs" role="tablist" aria-label="Lọc thông báo">
<button class="active" type="button" role="tab" aria-selected="true" data-notification-filter="all">Tất cả</button>
<button type="button" role="tab" aria-selected="false" data-notification-filter="unread">Chưa đọc</button>
</div>
<div class="notification-list" data-notification-list></div>
<div class="notification-empty" data-notification-empty hidden>
<span class="material-symbols-outlined">notifications_none</span>
<strong>Chưa có thông báo</strong>
<span>Các cập nhật quan trọng sẽ xuất hiện tại đây.</span>
</div>
<% if (currentUser.role === 'Admin') { %>
<div class="notification-panel-footer">
<button class="btn btn-primary" type="button" data-modal-open="systemNotificationModal">
<span class="material-symbols-outlined">campaign</span>
Đăng thông báo hệ thống
</button>
</div>
<% } %>
</section>
</div>
<% if (currentUser.role === 'User') { %> <% if (currentUser.role === 'User') { %>
<button class="profile-chip profile-chip-button" type="button" title="Cập nhật thông tin cá nhân" aria-label="Cập nhật thông tin cá nhân" data-modal-open="profileModal"> <button class="profile-chip profile-chip-button" type="button" title="Cập nhật thông tin cá nhân" aria-label="Cập nhật thông tin cá nhân" data-modal-open="profileModal">
<span class="profile-avatar"><%= currentUser.name.charAt(0) %></span> <span class="profile-avatar"><%= currentUser.name.charAt(0) %></span>