noti
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ web-client/dist/
|
||||
|
||||
web-server/uploads/
|
||||
docs/*
|
||||
output/
|
||||
tmp/
|
||||
770
design-language.md
Normal file
770
design-language.md
Normal 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ỏ (9–14px) **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ỏ (9–14px) + 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.0–7.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õ, 10–15 ổ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 1–2 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 5–6 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 5–6)* | `#3E89C0` | `#4FB0DD` |
|
||||
| `dataviz.cat-6` *(chỉ khi cần 5–6)* | `#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*62–70; 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 D135–D138 + 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ỡ 13–14**, 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ình–nề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
|
||||
|
||||
4–6 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` | 640–1024px | 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` và `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` D134–D142 (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
|
||||
73
web-server/database/04_notifications.sql
Normal file
73
web-server/database/04_notifications.sql
Normal 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
|
||||
@@ -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.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.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
|
||||
|
||||
@@ -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 -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\04_notifications.sql
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`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
|
||||
|
||||
1. Upload package mới:
|
||||
|
||||
@@ -225,6 +225,8 @@ button:disabled {
|
||||
height: 56px;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
position: relative;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.topbar-left,
|
||||
@@ -240,6 +242,256 @@ button:disabled {
|
||||
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 {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
@@ -419,6 +671,7 @@ button:disabled {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
|
||||
.dashboard-stats {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
@@ -1060,6 +1313,13 @@ tbody tr:hover td.action-col {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.modal-header p {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1464,6 +1724,15 @@ tbody tr:hover td.action-col {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.notification-panel {
|
||||
left: 8px;
|
||||
max-height: calc(100dvh - 72px);
|
||||
position: fixed;
|
||||
right: 8px;
|
||||
top: 64px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.profile-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
initFileDropzones();
|
||||
initRegistrationUniqueChecks();
|
||||
initProfileForms();
|
||||
initEditAppForms();
|
||||
initNotificationCenter();
|
||||
initSystemNotificationForm();
|
||||
|
||||
if (body.dataset.notice) {
|
||||
notify(body.dataset.noticeType || 'info', body.dataset.notice);
|
||||
|
||||
@@ -8,6 +8,7 @@ const path = require('path');
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const repository = require('./src/repository');
|
||||
const notificationRepository = require('./src/notification-repository');
|
||||
const mailer = require('./src/mailer');
|
||||
const { closePool, getPool } = require('./src/db');
|
||||
const notiflixVersion = require('notiflix/package.json').version;
|
||||
@@ -123,6 +124,7 @@ app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json({ limit: '32kb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
|
||||
app.use(applyPublicApiCors);
|
||||
@@ -524,6 +526,55 @@ function sanitizeReturnTo(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) {
|
||||
const requestBaseUrl = getRequestBaseUrl(req);
|
||||
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.');
|
||||
}
|
||||
|
||||
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) {
|
||||
const packageType = String(value || 'deb').toLowerCase();
|
||||
return ['deb', 'apt', 'docker'].includes(packageType) ? packageType : 'deb';
|
||||
@@ -1497,6 +1557,92 @@ exit 1
|
||||
app.use(requireAuthenticated);
|
||||
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) => {
|
||||
const pageData = await repository.getPageData(req.currentUser);
|
||||
const agentPackages = await listAgentPackages();
|
||||
@@ -1824,6 +1970,14 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
};
|
||||
|
||||
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.');
|
||||
} catch (error) {
|
||||
@@ -1833,6 +1987,15 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput);
|
||||
|
||||
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(
|
||||
res,
|
||||
`/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) => {
|
||||
const notificationImpact = await safelyLoadNotificationContext('package delete', () => (
|
||||
notificationRepository.getPackageImpact(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(
|
||||
res,
|
||||
'/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) => {
|
||||
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);
|
||||
|
||||
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(
|
||||
res,
|
||||
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 updated = await repository.updateApplicationStatus(applicationId, 'Released');
|
||||
|
||||
if (updated) {
|
||||
await safelyCreateNotification('application released', () => (
|
||||
notificationRepository.notifyApplicationReleased({
|
||||
applicationId,
|
||||
actorUserId: req.currentUser.id,
|
||||
actorName: getNotificationActorName(req.currentUser)
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
redirectWithNotice(
|
||||
res,
|
||||
returnTo,
|
||||
@@ -2237,6 +2450,13 @@ app.get('/builder', asyncRoute(async (req, res) => {
|
||||
app.use(async (error, req, res, next) => {
|
||||
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(() => ({
|
||||
currentUser: req.currentUser || { name: 'Guest', role: 'Guest', email: '' },
|
||||
stats: {
|
||||
|
||||
458
web-server/src/notification-repository.js
Normal file
458
web-server/src/notification-repository.js
Normal 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
|
||||
};
|
||||
@@ -55,6 +55,56 @@
|
||||
</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="/js/app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -52,9 +52,52 @@
|
||||
<button class="icon-button" type="button" title="Đồng bộ dữ liệu" data-refresh-page>
|
||||
<span class="material-symbols-outlined">sync</span>
|
||||
</button>
|
||||
<button class="icon-button" type="button" title="Thông báo" data-toast="Chưa có thông báo mới">
|
||||
<span class="material-symbols-outlined">notifications</span>
|
||||
</button>
|
||||
<div class="notification-center" data-notification-center>
|
||||
<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') { %>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user