Extension store — cắm UI liên module
Vì sao cần cơ chế này
Phần tiêu đề “Vì sao cần cơ chế này”Trang chi tiết contact (/contacts/:id/:tab) muốn hiển thị “Conversations” (Helpdesk) hoặc “Employee record” (HR) ngay trong tab của nó — nhưng Contacts không nên import trực tiếp component của Helpdesk/HR (sẽ tạo dependency ngược, và Contacts phải biết trước mọi module sẽ cắm vào nó, kể cả module chưa tồn tại). Extension store giải quyết bằng registry pattern: bên cung cấp UI đăng ký vào một pointId; bên host chỉ cần biết pointId đó, đọc list bằng getVisibleExtensions() mà không cần biết ai đã đăng ký.
Vị trí sống: @odp/core, không phải Contacts
Phần tiêu đề “Vị trí sống: @odp/core, không phải Contacts”export const useExtensionStore = defineStore('extensions', () => { const registry = ref<Map<string, ExtensionRegistration[]>>(new Map())
function register(pointId: string, registration: ExtensionRegistration) { const current = registry.value.get(pointId) || [] const idx = current.findIndex(e => e.id === registration.id) if (idx >= 0) current[idx] = registration else current.push(registration) registry.value.set(pointId, current) }
function getVisibleExtensions(pointId: string, context: Record<string, any>): ExtensionRegistration[] { return getExtensions(pointId).filter(ext => !ext.visible || ext.visible(context)) }
return { register, getExtensions, getVisibleExtensions }})Pinia store id 'extensions' là một instance duy nhất trong toàn app — mọi module extends @odp/core chia sẻ chung một registry, tự động auto-import, không cần cấu hình gì thêm để “kết nối” hai module.
Contacts tự bọc một lớp fallback an toàn khi chạy standalone (không có host store):
export function useContactExtensionStore(): ExtensionStore { try { // @ts-expect-error — auto-imported from host app when available return useExtensionStore() as ExtensionStore } catch { return NOOP_STORE }}Hình dạng một registration
Phần tiêu đề “Hình dạng một registration”interface ExtensionRegistration { id: string // duy nhất trong 1 pointId label: string icon?: string sort?: number // mặc định 50 nếu bỏ trống visible?: (context: Record<string, any>) => boolean // ẩn/hiện theo context badge?: (context: Record<string, any>) => string | number | undefined component?: Component // tab/widget extension handler?: (context: Record<string, any>) => void | Promise<void> // action extension color?: string variant?: string}Cùng một interface phục vụ 2 kiểu mở rộng: tab/widget (cung cấp component, host render) và action (cung cấp handler, host gọi khi click) — điểm mở rộng nào dùng kiểu nào là do tài liệu của điểm đó quy định, không phải do interface ép buộc.
Ví dụ thật: Helpdesk cắm tab “Conversations”
Phần tiêu đề “Ví dụ thật: Helpdesk cắm tab “Conversations””export default defineNuxtPlugin(() => { const ext = useExtensionStore()
ext.register("contacts.detail.tabs", { id: "helpdesk", label: "Conversations", icon: "i-ph-chat-circle-light", component: defineAsyncComponent( () => import("../components/contacts/helpdesk-contact-tab.vue"), ), sort: 30, visible: (ctx: { contact?: { type?: string } }) => ctx.contact?.type === "person", })})Ba điểm đáng chú ý:
componentlàdefineAsyncComponent— Helpdesk chỉ tải component thật khi tab được render, không load sẵn lúc app boot.visiblenhậncontext— cùng một điểm mở rộng, Helpdesk chỉ hiện tab khi contact làperson(organization không có “Conversations”).- Plugin này nằm trong Helpdesk, không phải Contacts — Contacts hoàn toàn không biết Helpdesk tồn tại.
Phía Contacts: đọc và render extension
Phần tiêu đề “Phía Contacts: đọc và render extension”const extensionTabs = computed(() => { if (!contact.value) return [] return extensionStore.getVisibleExtensions("contacts.detail.tabs", { contact: contact.value, })})
const allTabs = computed(() => { const extensions = extensionTabs.value.map(ext => ({ id: ext.id, label: ext.label, icon: ext.icon, sort: ext.sort ?? 50, badge: ext.badge ? ext.badge({ contact: contact.value }) : undefined, })) return [...coreTabs.value, ...extensions].sort((a, b) => (a.sort ?? 50) - (b.sort ?? 50))})Component thật được resolve ở trang con ([id]/[tab].vue), không phải nơi đăng ký:
// resolveComponent runs here in component context (not in the registering plugin),// so a registered component *name* string resolves reliably.const extComponent = computed(() => { const comp = extReg.value?.component if (!comp) return null return typeof comp === "string" ? resolveComponent(comp) : comp})Một tab segment trên URL không khớp core tab lẫn extension đang visible (typo, link cũ, extension bị ẩn cho contact này) sẽ tự navigateTo redirect về info — tránh trang trắng.
useContactTypeRegistry — extension point khác, cùng ý tưởng
Phần tiêu đề “useContactTypeRegistry — extension point khác, cùng ý tưởng”Ngoài tab, Contacts có một điểm mở rộng tương tự cho type (loại quan hệ): module ngoài có thể register() một type riêng (vd hr:employee) hiện trong danh sách type của Contacts mà không cần ghi vào shared_types backend:
export function useContactTypeRegistry() { const registry = useState<ContactTypeDef[]>(REGISTRY_STATE_KEY, () => []) function register(def: ContactTypeDef): void { /* upsert theo key */ } return { registry, register, unregister, getRegisteredMeta }}useContactsSettings() hợp nhất backendTypes (từ shared_types) với contributedTypes (từ registry này) — backend thắng khi trùng key, key chỉ-contributed được nối thêm vào cuối danh sách hiển thị.
Đọc tiếp
Phần tiêu đề “Đọc tiếp”- Hồ sơ chi tiết — nơi tab mở rộng xuất hiện trên UI
- Kiến trúc tổng thể — bức tranh 4 tầng
- Mở rộng & tuỳ biến — cách đăng ký một extension tab mới từ module của bạn