关于 Directus UI Extensions Mastery
这款指导 Skill 面向为 Directus 构建 Vue 3 UI 扩展的开发者,涵盖面板、界面、显示、布局和模块。内容包括 Extensions SDK、实现模式、测试、部署与性能实践。
主要功能
- 01涵盖五类扩展
- 02Vue 3 Composition API 模式
- 03Directus SDK 集成
- 04Vitest 测试示例
- 05构建与部署指导
适用场景
- 01创建自定义 Directus 仪表板面板
- 02构建专用数据录入界面
- 03开发集合显示或布局
- 04测试并部署 Directus 扩展
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ gumpen-app/ directapp --skill directus-ui-extensions-mastery安装后在 Claude Code 中输入/directus-ui-extensions-mastery确认已加载。
附带文件examples/analytics-panel.vue
详细介绍
Directus UI 扩展精通
概述
本技能提供使用 Directus 构建可用于生产环境的 Vue 3 UI 扩展的专家指导。掌握如何使用 @directus/extensions-sdk 和现代 Vue 3 Composition API 模式创建自定义面板、接口、显示和布局。实现实时数据可视化、响应式设计,并与 Directus 组件库无缝集成。
何时使用此技能
- 为数据可视化构建自定义仪表盘面板
- 为复杂数据类型创建专用输入接口
- 开发自定义集合显示和布局
- 使用 WebSocket 集成实现实时组件
- 添加玻璃拟态或现代 UI 设计模式
- 确保移动端功能一致并支持响应式设计
- 与 Directus 主题系统集成
- 为团队创建可复用的 UI 组件
核心概念
扩展类型
- 面板 - Insights 模块的仪表盘小组件
- 接口 - 用于数据录入的自定义输入组件
- 显示 - 自定义字段值渲染
- 布局 - 替代的集合视图
- 模块 - Directus 中完整的自定义部分
技术栈
- Vue 3,使用 Composition API
- TypeScript,用于类型安全
- @directus/extensions-sdk,用于 Directus 集成
- Vite,用于构建和开发
- Pinia,用于状态管理(通过 useStores)
- Vue Router,用于导航(在模块中)
流程:构建自定义面板
步骤 1:初始化扩展
# Create new panel extension
npx create-directus-extension@latest
# Select options:
# > panel
# > my-custom-panel
# > typescript
步骤 2:配置面板元数据
// src/index.ts
import { definePanel } from '@directus/extensions-sdk';
import PanelComponent from './panel.vue';
export default definePanel({
id: 'custom-analytics',
name: 'Analytics Dashboard',
icon: 'analytics',
description: 'Real-time analytics and metrics',
component: PanelComponent,
minWidth: 12,
minHeight: 8,
options: [
{
field: 'collection',
type: 'string',
name: 'Collection',
meta: {
interface: 'system-collection',
width: 'half',
},
},
{
field: 'dateField',
type: 'string',
name: 'Date Field',
meta: {
interface: 'system-field',
options: {
collectionField: 'collection',
typeAllowList: ['datetime', 'date', 'timestamp'],
},
width: 'half',
},
},
{
field: 'refreshInterval',
type: 'integer',
name: 'Refresh Interval (seconds)',
meta: {
interface: 'input',
width: 'half',
},
schema: {
default_value: 30,
},
},
],
});
步骤 3:实现 Vue 组件
<template>
<div class="analytics-panel">
<div v-if="loading" class="loading-state">
<v-progress-circular indeterminate />
</div>
<div v-else-if="error" class="error-state">
<v-notice type="danger">
{{ error }}
</v-notice>
</div>
<div v-else class="panel-content">
<div class="metrics-grid">
<div class="metric-card" v-for="metric in metrics" :key="metric.id">
<div class="metric-value">{{ formatNumber(metric.value) }}</div>
<div class="metric-label">{{ metric.label }}</div>
<div class="metric-change" :class="metric.trend">
<v-icon :name="getTrendIcon(metric.trend)" small />
{{ metric.change }}%
</div>
</div>
</div>
<div class="chart-container">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useApi, useStores } from '@directus/extensions-sdk';
import { Chart, registerables } from 'chart.js';
// Register Chart.js components
Chart.register(...registerables);
// Props from panel configuration
interface Props {
collection?: string;
dateField?: string;
refreshInterval?: number;
showHeader?: boolean;
height: number;
width: number;
}
const props = withDefaults(defineProps<Props>(), {
refreshInterval: 30,
showHeader: true,
});
// Composables
const api = useApi();
const { useItemsStore } = useStores();
const itemsStore = useItemsStore();
// Reactive state
const loading = ref(true);
const error = ref<string | null>(null);
const metrics = ref([]);
const chartData = ref([]);
const chart = ref<Chart | null>(null);
const chartCanvas = ref<HTMLCanvasElement>();
const refreshTimer = ref<NodeJS.Timeout>();
// Computed properties
const chartConfig = computed(() => ({
type: 'line',
data: {
labels: chartData.value.map(d => formatDate(d.date)),
datasets: [{
label: 'Activity',
data: chartData.value.map(d => d.value),
borderColor: 'var(--theme--primary)',
backgroundColor: 'var(--theme--primary-background)',
tension: 0.4,
fill: true,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'var(--theme--background-accent)',
titleColor: 'var(--theme--foreground)',
bodyColor: 'var(--theme--foreground-subdued)',
},
},
scales: {
x: {
grid: {
color: 'var(--theme--border-color-subdued)',
},
ticks: {
color: 'var(--theme--foreground-subdued)',
},
},
y: {
grid: {
color: 'var(--theme--border-color-subdued)',
},
ticks: {
color: 'var(--theme--foreground-subdued)',
},
},
},
},
}));
// Methods
async function fetchData() {
if (!props.collection) {
error.value = 'No collection selected';
loading.value = false;
return;
}
try {
loading.value = true;
error.value = null;
// Fetch aggregate data for metrics
const { data: aggregateData } = await api.get(`/items/${props.collection}`, {
params: {
aggregate: {
count: '*',
avg: 'amount',
sum: 'amount',
},
groupBy: props.dateField ? [props.dateField] : undefined,
filter: props.dateField ? {
[props.dateField]: {
_between: [getStartDate(), getEndDate()],
},
} : undefined,
limit: -1,
},
});
// Process metrics
processMetrics(aggregateData);
// Fetch time series data for chart
if (props.dateField) {
const { data: timeSeriesData } = await api.get(`/items/${props.collection}`, {
params: {
fields: [props.dateField, 'amount'],
filter: {
[props.dateField]: {
_between: [getStartDate(), getEndDate()],
},
},
sort: [props.dateField],
limit: 100,
},
});
processChartData(timeSeriesData);
}
loading.value = false;
} catch (err) {
console.error('Error fetching data:', err);
error.value = 'Failed to load data';
loading.value = false;
}
}
function processMetrics(data: any) {
// Calculate and format metrics
const total = data?.count || 0;
const average = data?.avg?.amount || 0;
const sum = data?.sum?.amount || 0;
metrics.value = [
{
id: 'total',
label: 'Total Items',
value: total,
change: calculateChange(total, 'previous'),
trend: total > 0 ? 'up' : 'neutral',
},
{
id: 'average',
label: 'Average Value',
value: average,
change: calculateChange(average, 'previous'),
trend: average > 0 ? 'up' : 'down',
},
{
id: 'sum',
label: 'Total Value',
value: sum,
change: calculateChange(sum, 'previous'),
trend: sum > 0 ? 'up' : 'neutral',
},
];
}
function processChartData(data: any[]) {
// Aggregate data by date
const aggregated = data.reduce((acc, item) => {
const date = new Date(item[props.dateField!]).toLocaleDateString();
if (!acc[date]) {
acc[date] = { date, value: 0, count: 0 };
}
acc[date].value += item.amount || 0;
acc[date].count++;
return acc;
}, {});
chartData.value = Object.values(aggregated);
updateChart();
}
function updateChart() {
if (!chartCanvas.value) return;
if (chart.value) {
chart.value.destroy();
}
chart.value = new Chart(chartCanvas.value, chartConfig.value as any);
}
function setupAutoRefresh() {
if (props.refreshInterval && props.refreshInterval > 0) {
refreshTimer.value = setInterval(() => {
fetchData();
}, props.refreshInterval * 1000);
}
}
function cleanupAutoRefresh() {
if (refreshTimer.value) {
clearInterval(refreshTimer.value);
}
}
// Utility functions
function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value);
}
function formatDate(date: string): string {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}
function getTrendIcon(trend: string): string {
switch (trend) {
case 'up': return 'trending_up';
case 'down': return 'trending_down';
default: return 'trending_flat';
}
}
function calculateChange(current: number, previous: number | string): number {
// Mock calculation - replace with actual logic
return Math.round(Math.random() * 20 - 10);
}
function getStartDate(): string {
const date = new Date();
date.setDate(date.getDate() - 30);
return date.toISOString();
}
function getEndDate(): string {
return new Date().toISOString();
}
// Lifecycle hooks
onMounted(() => {
fetchData();
setupAutoRefresh();
});
onUnmounted(() => {
cleanupAutoRefresh();
if (chart.value) {
chart.value.destroy();
}
});
// Watchers
watch(() => props.collection, () => {
fetchData();
});
watch(() => props.refreshInterval, () => {
cleanupAutoRefresh();
setupAutoRefresh();
});
</script>
<style scoped>
.analytics-panel {
height: 100%;
display: flex;
flex-direction: column;
padding: var(--content-padding);
background: var(--theme--background);
border-radius: var(--theme--border-radius);
}
.loading-state,
.error-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.panel-content {
display: flex;
flex-direction: column;
gap: var(--spacing-l);
height: 100%;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--spacing-m);
}
.metric-card {
padding: var(--spacing-m);
background: var(--theme--background-accent);
border-radius: var(--theme--border-radius);
border: 1px solid var(--theme--border-color-subdued);
}
.metric-value {
font-size: 1.75rem;
font-weight: 600;
color: var(--theme--primary);
line-height: 1.2;
}
.metric-label {
font-size: 0.875rem;
color: var(--theme--foreground-subdued);
margin-top: var(--spacing-xs);
}
.metric-change {
display: flex;
align-items: center;
gap: var(--spacing-xs);
margin-top: var(--spacing-s);
font-size: 0.875rem;
}
.metric-change.up {
color: var(--theme--success);
}
.metric-change.down {
color: var(--theme--danger);
}
.metric-change.neutral {
color: var(--theme--foreground-subdued);
}
.chart-container {
flex: 1;
position: relative;
min-height: 200px;
}
/* Responsive design */
@media (max-width: 768px) {
.metrics-grid {
grid-template-columns: 1fr;
}
.metric-card {
padding: var(--spacing-s);
}
.metric-value {
font-size: 1.5rem;
}
}
/* Glass morphism variant */
.analytics-panel.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.analytics-panel.glass .metric-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
</style>
流程:构建自定义接口
步骤 1:接口结构
// src/index.ts
import { defineInterface } from '@directus/extensions-sdk';
import InterfaceComponent from './interface.vue';
export default defineInterface({
id: 'color-palette',
name: 'Color Palette',
icon: 'palette',
description: 'Select colors from a predefined palette',
component: InterfaceComponent,
types: ['string', 'json'],
group: 'selection',
options: [
{
field: 'palette',
type: 'json',
name: 'Color Palette',
meta: {
interface: 'code',
options: {
language: 'json',
},
},
schema: {
default_value: [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FECA57', '#FF9FF3',
],
},
},
{
field: 'allowMultiple',
type: 'boolean',
name: 'Allow Multiple Selection',
meta: {
interface: 'boolean',
width: 'half',
},
schema: {
default_value: false,
},
},
],
});
步骤 2:接口组件
<template>
<div class="color-palette-interface">
<div class="color-grid">
<button
v-for="color in palette"
:key="color"
class="color-swatch"
:class="{ selected: isSelected(color) }"
:style="{ backgroundColor: color }"
@click="toggleColor(color)"
:title="color"
>
<v-icon
v-if="isSelected(color)"
name="check"
small
/>
</button>
</div>
<div v-if="selectedColors.length > 0" class="selected-display">
<span class="label">Selected:</span>
<div class="selected-colors">
<span
v-for="color in selectedColors"
:key="color"
class="color-tag"
:style="{ backgroundColor: color }"
>
{{ color }}
<v-icon
name="close"
x-small
@click="removeColor(color)"
/>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
interface Props {
value: string | string[] | null;
palette?: string[];
allowMultiple?: boolean;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
palette: () => [
'#FF6B6B', '#4ECDC4', '#45B7D1',
'#96CEB4', '#FECA57', '#FF9FF3',
],
allowMultiple: false,
disabled: false,
});
const emit = defineEmits<{
input: [value: string | string[] | null];
}>();
const selectedColors = ref<string[]>([]);
// Initialize selected colors from value
watch(() => props.value, (newValue) => {
if (Array.isArray(newValue)) {
selectedColors.value = newValue;
} else if (newValue) {
selectedColors.value = [newValue];
} else {
selectedColors.value = [];
}
}, { immediate: true });
function isSelected(color: string): boolean {
return selectedColors.value.includes(color);
}
function toggleColor(color: string) {
if (props.disabled) return;
if (props.allowMultiple) {
if (isSelected(color)) {
removeColor(color);
} else {
selectedColors.value.push(color);
emitValue();
}
} else {
selectedColors.value = [color];
emitValue();
}
}
function removeColor(color: string) {
if (props.disabled) return;
const index = selectedColors.value.indexOf(color);
if (index > -1) {
selectedColors.value.splice(index, 1);
emitValue();
}
}
function emitValue() {
if (props.allowMultiple) {
emit('input', selectedColors.value.length > 0 ? selectedColors.value : null);
} else {
emit('input', selectedColors.value[0] || null);
}
}
</script>
<style scoped>
.color-palette-interface {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
}
.color-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: var(--spacing-s);
}
.color-swatch {
aspect-ratio: 1;
border-radius: var(--theme--border-radius);
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.color-swatch:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.color-swatch.selected {
border-color: var(--theme--primary);
box-shadow: 0 0 0 3px var(--theme--primary-background);
}
.color-swatch:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.selected-display {
display: flex;
align-items: center;
gap: var(--spacing-m);
padding: var(--spacing-s);
background: var(--theme--background-accent);
border-radius: var(--theme--border-radius);
}
.label {
font-size: 0.875rem;
color: var(--theme--foreground-subdued);
}
.selected-colors {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.color-tag {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-s);
border-radius: var(--theme--border-radius);
color: white;
font-size: 0.75rem;
font-weight: 500;
}
.color-tag .v-icon {
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
}
.color-tag .v-icon:hover {
opacity: 1;
}
</style>
使用 Directus 组合式函数
可用的组合式函数
import {
useApi, // API client for making requests
useStores, // Access to Directus stores
useSync, // Sync data between components
useCollection, // Collection metadata
useItems, // Items management
useLayout, // Layout configuration
usePermissions,// User permissions
useFilterFields,// Field filtering
} from '@directus/extensions-sdk';
API 使用示例
// Fetch data from API
const api = useApi();
// GET request
const response = await api.get('/items/articles', {
params: {
filter: { status: { _eq: 'published' } },
limit: 10,
sort: ['-date_created'],
},
});
// POST request
await api.post('/items/comments', {
article: 1,
content: 'Great article!',
author: 'current-user-id',
});
// File upload
const formData = new FormData();
formData.append('file', fileBlob);
await api.post('/files', formData);
Store 使用示例
const { useItemsStore, useCollectionsStore, useFieldsStore } = useStores();
// Items store
const itemsStore = useItemsStore();
const items = await itemsStore.getItems('articles', {
limit: 10,
fields: ['id', 'title', 'content'],
});
// Collections store
const collectionsStore = useCollectionsStore();
const collections = collectionsStore.collections;
// Fields store
const fieldsStore = useFieldsStore();
const fields = fieldsStore.getFieldsForCollection('articles');
使用 WebSocket 实现实时功能
设置 WebSocket 连接
import { useApi } from '@directus/extensions-sdk';
import { io, Socket } from 'socket.io-client';
const api = useApi();
let socket: Socket | null = null;
function connectWebSocket() {
// Get the API URL
const baseURL = api.defaults.baseURL || window.location.origin;
socket = io(baseURL, {
transports: ['websocket'],
auth: {
access_token: api.defaults.headers.common['Authorization']?.replace('Bearer ', ''),
},
});
socket.on('connect', () => {
console.log('WebSocket connected');
subscribeToCollections();
});
socket.on('subscription', handleRealtimeUpdate);
}
function subscribeToCollections() {
if (!socket) return;
socket.emit('subscribe', {
collection: 'articles',
query: {
fields: ['*'],
filter: { status: { _eq: 'published' } },
},
});
}
function handleRealtimeUpdate(data: any) {
// Handle real-time updates
if (data.action === 'create') {
// New item created
} else if (data.action === 'update') {
// Item updated
} else if (data.action === 'delete') {
// Item deleted
}
}
主题集成
使用主题变量
/* Available theme variables */
.my-component {
/* Colors */
color: var(--theme--foreground);
background: var(--theme--background);
border-color: var(--theme--border-color);
/* Primary colors */
background: var(--theme--primary);
background: var(--theme--primary-background);
background: var(--theme--primary-subdued);
/* Semantic colors */
color: var(--theme--success);
color: var(--theme--warning);
color: var(--theme--danger);
/* Spacing */
padding: var(--spacing-s);
margin: var(--spacing-m);
gap: var(--spacing-l);
/* Border radius */
border-radius: var(--theme--border-radius);
/* Typography */
font-family: var(--theme--fonts--sans--font-family);
font-size: var(--theme--fonts--sans--font-size);
/* Shadows */
box-shadow: var(--theme--shadow);
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.my-component {
background: var(--theme--background-accent);
}
}
使用 Directus 组件库
导入组件
<template>
<div class="my-extension">
<v-button @click="handleClick" :loading="isLoading">
Click Me
</v-button>
<v-input
v-model="inputValue"
placeholder="Enter text..."
:disabled="isDisabled"
/>
<v-notice type="info">
This is an informational notice
</v-notice>
<v-dialog
v-model="dialogOpen"
title="Confirm Action"
@confirm="handleConfirm"
>
Are you sure you want to proceed?
</v-dialog>
<v-progress-circular
v-if="loading"
indeterminate
/>
</div>
</template>
可用组件
- 表单:v-input、v-textarea、v-select、v-checkbox、v-radio
- 按钮:v-button、v-button-group、v-icon-button
- 反馈:v-notice、v-dialog、v-tooltip、v-progress-circular
- 布局:v-card、v-divider、v-tabs、v-drawer
- 数据:v-table、v-pagination、v-chip
- 导航:v-breadcrumb、v-menu、v-list
移动端响应式设计
响应式网格系统
<template>
<div class="responsive-layout">
<div class="grid-container">
<div class="grid-item" v-for="item in items" :key="item.id">
</div>
</div>
</div>
</template>
<style scoped>
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--spacing-m);
}
/* Tablet */
@media (max-width: 768px) {
.grid-container {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-s);
}
}
/* Mobile */
@media (max-width: 480px) {
.grid-container {
grid-template-columns: 1fr;
gap: var(--spacing-xs);
}
}
/* Touch-friendly interactions */
@media (hover: none) {
.clickable-element {
min-height: 44px; /* iOS touch target */
min-width: 44px;
}
}
</style>
测试扩展
使用 Vitest 进行单元测试
// test/panel.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import PanelComponent from '../src/panel.vue';
// Mock Directus SDK
vi.mock('@directus/extensions-sdk', () => ({
useApi: () => ({
get: vi.fn().mockResolvedValue({ data: [] }),
post: vi.fn(),
}),
useStores: () => ({
useItemsStore: () => ({
getItems: vi.fn().mockResolvedValue([]),
}),
}),
}));
describe('Analytics Panel', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(PanelComponent, {
props: {
collection: 'test_collection',
dateField: 'created_at',
height: 400,
width: 600,
},
});
});
it('renders loading state initially', () => {
expect(wrapper.find('.loading-state').exists()).toBe(true);
});
it('displays metrics after data loads', async () => {
// Wait for async operations
await wrapper.vm.$nextTick();
await new Promise(resolve => setTimeout(resolve, 100));
expect(wrapper.findAll('.metric-card').length).toBeGreaterThan(0);
});
it('handles error state gracefully', async () => {
wrapper.vm.error = 'Test error';
await wrapper.vm.$nextTick();
expect(wrapper.find('.error-state').exists()).toBe(true);
expect(wrapper.text()).toContain('Test error');
});
});
部署
构建流程
# Development
npm run dev
# Production build
npm run build
# Output structure
dist/
├── index.js # Compiled extension
├── index.css # Styles (if any)
└── package.json # Extension metadata
安装方式
- 通过 NPM:
npm install directus-extension-my-panel
- 通过 Extensions 文件夹:
# Copy to extensions directory
cp -r dist/ /directus/extensions/panels/my-panel/
- 通过管理面板:
- 前往 Settings → Extensions
- 上传 .tar.gz 软件包
最佳实践
性能优化
- 使用计算属性派生状态
- 实现虚拟滚动以处理大型列表
- 对 API 调用进行防抖处理,用于搜索/筛选输入
- 延迟加载较重的组件
- 适当时缓存 API 响应
- 使用 Web Workers 处理繁重计算
代码组织
extension/
├── src/
│ ├── components/ # Reusable components
│ ├── composables/ # Shared logic
│ ├── utils/ # Helper functions
│ ├── types/ # TypeScript types
│ ├── index.ts # Extension entry
│ └── panel.vue # Main component
├── test/
│ └── *.test.ts # Test files
├── package.json
└── tsconfig.json
错误处理
// Comprehensive error handling
try {
const response = await api.get('/items/collection');
// Process response
} catch (error) {
// User-friendly error messages
if (error.response?.status === 403) {
showNotification({
type: 'error',
title: 'Permission Denied',
description: 'You don\'t have access to this resource',
});
} else if (error.response?.status === 404) {
showNotification({
type: 'warning',
title: 'Not Found',
description: 'The requested resource was not found',
});
} else {
showNotification({
type: 'error',
title: 'Error',
description: error.message || 'An unexpected error occurred',
});
}
// Log for debugging
console.error('[Extension Error]:', error);
}
故障排除指南
常见问题和解决方案
-
扩展未加载
- 检查 index.ts 中
id的唯一性 - 验证 dist/ 中的构建输出
- 检查浏览器控制台中的错误
- 确保 Directus 版本兼容
- 检查 index.ts 中
-
API 调用失败
- 验证用户权限
- 检查 API 端点路径
- 验证身份验证令牌
- 检查 CORS 设置
-
样式问题
- 使用 Directus 主题变量
- 检查 CSS 作用域
- 在浅色/深色模式下测试
- 验证响应式断点
-
性能问题
- 使用 Vue DevTools 进行性能分析
- 检查 API 查询效率
- 实现分页
- 优化响应式依赖项
成功指标
- ✅ 扩展加载时没有错误
- ✅ 数据获取和显示正确
- ✅ 实时更新正常工作(如果已实现)
- ✅ 移动设备上的响应式设计正常运行
- ✅ 主题集成流畅无缝
- ✅ 性能流畅(交互耗时 < 100ms)
- ✅ 错误状态得到妥善处理
- ✅ 符合无障碍标准
- ✅ 正确定义 TypeScript 类型
- ✅ 单元测试通过率 > 80%
资源
- Directus 扩展 SDK 文档
- Vue 3 Composition API
- Directus 组件库 Storybook
- TypeScript Vue 支持
- Vite 配置
- Chart.js 文档
版本历史
- 1.0.0 - 首次发布,包含全面的 Vue 3 扩展模式
评论
支持 Markdown