Giao diện
Kiến trúc kỹ thuật
Tổng thể
compass.conan1.com api.conan1.com D1
Cloudflare Pages Cloudflare Worker conan-compass
───────────────── ────────────────── ─────────────
Vite 8 + React 19 Hono capabilities
Tailwind v4 SQL thuần brands
shadcn + radix-ui Access JWT verify brand_capabilities
Heroicons processes / activities
Motion applications
React Router 7 data_entities
technologies
value_streams
initiatives / kpis
users / audit_logVì sao tải toàn bộ mô hình một lần
Mô hình chỉ có vài chục năng lực và một nắm Brand — tổng cộng vài chục KB JSON. Khi khởi động, CompassProvider tải:
/v1/me/v1/capabilities/v1/brands/v1/architecture— toàn bộ tầng thực thi và chuyển đổi trong một request/v1/brands/:code/capabilitiescho từng Brand
Sau đó mọi màn hình phân tích được tính ngay tại client. Cross-Brand Comparison, Heatmap, Gaps, Risk Map và các thẻ trên Overview không cần thêm một round-trip nào, và không cần một endpoint riêng cho từng biểu đồ.
Sau mỗi lần ghi, ứng dụng gọi reload() để tải lại — đơn giản và luôn nhất quán với server. Nếu sau này mô hình phình lên hàng nghìn năng lực, đây là chỗ đầu tiên cần đổi.
Cấu trúc mã nguồn
compass.conan1.com/
src/
lib/
api.ts Client gọi api.conan1.com
store.tsx CompassProvider — nguồn dữ liệu duy nhất
scales.ts Thang điểm, công thức risk và gap
types.ts
i18n/
dictionary.ts Từ điển Việt – Anh
index.tsx I18nProvider, useI18n
components/
ui/ shadcn
AppShell.tsx Sidebar, topbar, chuyển trang
Score.tsx ScoreChip, RiskChip, GapChip
*Dialog.tsx Form thêm/sửa
pages/ Mỗi route một fileNguồn dữ liệu duy nhất
useCompass() trả về mô hình đã chuẩn hoá cùng vài chỉ mục dựng sẵn:
| Trường | Ý nghĩa |
|---|---|
byId | Map<capability_id, Capability> |
childrenOf | Map<parent_id | null, Capability[]> đã sắp xếp |
pathOf(id) | Đường dẫn từ L1 tới năng lực đó, dùng cho breadcrumb |
maps[brandCode][capabilityId] | Đánh giá của một Brand |
arch | Tầng thực thi: process, application, data, technology, value stream, initiative, KPI |
canEditModel, canEditBrand(code), canEditScope | Quyền, tính từ vai trò và phạm vi đang chọn |
Các hook phụ trợ trả về dữ liệu đã lọc theo bộ chọn phạm vi, nên mỗi trang chỉ cần lo phần trình bày:
| Hook | Trả về |
|---|---|
useScopedBrands() | Brand trong phạm vi |
useScopedRows() | Mọi đánh giá năng lực trong phạm vi, đã làm phẳng |
useScopeBrandId() | brand_id hiện tại, hoặc null ở Enterprise |
inScope(rows, brandId) | Lọc bản ghi của Brand cộng bản ghi dùng chung |
indexLinks(links, from, to) | Gom bảng nối thành Map<a, b[]> |
Animation
AppShell bọc nội dung trong AnimatePresence mode="wait" với key là location.pathname: fade + dịch 6px, 180ms. Danh sách và thẻ fade-in so le, độ trễ tối đa 0.24s. prefers-reduced-motion được tôn trọng ở tầng CSS.
Toàn bộ cấu hình animation nằm trong src/lib/motion.ts (enter() và pageTransition()), không rải rác trong từng trang.
Tab ẩn
Animation chạy trên requestAnimationFrame, mà trình duyệt bóp nghẹt rAF ở tab đang ẩn. Một phần tử bắt đầu ở opacity: 0 sẽ đứng yên vô hình cho tới khi người dùng chuyển sang tab đó — nghĩa là trang mở ở tab nền hiện ra trắng trơn.
Vì vậy enter() và pageTransition() kiểm tra document.visibilityState khi mount: nếu tài liệu đang ẩn thì bỏ animation và render thẳng trạng thái cuối.