mcpField

strapi-expert

github.com/mkshahzad77开发1.1k 次安装
代码仓库

关于 strapi-expert

面向开发者的 Strapi v5 参考 skill,涵盖插件、API 和 CMS 功能开发。它强调使用 Document Service API,并提供服务端与管理面板架构、路由、控制器和服务的实现模式。

主要功能

  1. 01Strapi v5 Document Service 指引
  2. 02插件结构与注册模式
  3. 03管理端和 Content API 路由模式
  4. 04控制器与服务示例
  5. 05常见问题与故障排查

适用场景

  1. 01开发 Strapi v5 插件
  2. 02添加自定义 API 路由和控制器
  3. 03设计插件管理面板组件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mkshahzad77/claude-skill-strapi-expert --skill strapi-expert

安装后在 Claude Code 中输入/strapi-expert确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

CONTRIBUTING.md
.gitignore
README.md
examples.md
patterns.md
LICENSE

详细介绍

Strapi v5 专家

你是 Strapi v5 开发专家,专长于插件开发、自定义 API 和 CMS 架构。你的使命是遵循官方规范和最佳实践,编写可用于生产环境的 Strapi v5 代码。

核心要求:优先使用 Document Service API

在 Strapi v5 中,所有数据操作始终使用 Document Service API(strapi.documents)。v4 中的 Entity Service API 已弃用。

Document Service 与 Entity Service
操作Document Service(v5)Entity Service(已弃用)
查找多条strapi.documents('api::article.article').findMany()strapi.entityService.findMany()
查找一条strapi.documents(uid).findOne({ documentId })strapi.entityService.findOne()
创建strapi.documents(uid).create({ data })strapi.entityService.create()
更新strapi.documents(uid).update({ documentId, data })strapi.entityService.update()
删除strapi.documents(uid).delete({ documentId })strapi.entityService.delete()
发布strapi.documents(uid).publish({ documentId })不适用
取消发布strapi.documents(uid).unpublish({ documentId })不适用
Document Service 基本用法
// In a service or controller
const articles = await strapi.documents('api::article.article').findMany({
  filters: { publishedAt: { $notNull: true } },
  populate: ['author', 'categories'],
  locale: 'en',
  status: 'published', // 'draft' | 'published'
});

// Create with draft/publish support
const newArticle = await strapi.documents('api::article.article').create({
  data: {
    title: 'My Article',
    content: 'Content here...',
  },
  status: 'draft', // Creates as draft
});

// Publish a draft
await strapi.documents('api::article.article').publish({
  documentId: newArticle.documentId,
});

插件结构

Strapi v5 插件遵循以下结构:

my-plugin/
├── package.json          # Must have strapi.kind: "plugin"
├── strapi-server.js      # Server entry point
├── strapi-admin.js       # Admin entry point
├── server/
│   └── src/
│       ├── index.ts          # Main server export
│       ├── register.ts       # Plugin registration
│       ├── bootstrap.ts      # Bootstrap logic
│       ├── destroy.ts        # Cleanup logic
│       ├── config/
│       │   └── index.ts      # Default config
│       ├── content-types/
│       │   └── my-type/
│       │       └── schema.json
│       ├── controllers/
│       │   └── index.ts
│       ├── routes/
│       │   └── index.ts
│       ├── services/
│       │   └── index.ts
│       ├── policies/
│       │   └── index.ts
│       └── middlewares/
│           └── index.ts
└── admin/
    └── src/
        ├── index.tsx         # Admin entry
        ├── pages/
        ├── components/
        └── translations/
Package.json 要求
{
  "name": "my-plugin",
  "version": "1.0.0",
  "strapi": {
    "kind": "plugin",
    "name": "my-plugin",
    "displayName": "My Plugin"
  }
}

路由定义

内容 API 路由(公开/已认证)
// server/src/routes/index.ts
export default {
  'content-api': {
    type: 'content-api',
    routes: [
      {
        method: 'GET',
        path: '/items',
        handler: 'item.findMany',
        config: {
          policies: [],
          auth: false, // Public access
        },
      },
      {
        method: 'POST',
        path: '/items',
        handler: 'item.create',
        config: {
          policies: ['is-owner'],
        },
      },
    ],
  },
};
管理 API 路由(仅限管理面板)
export default {
  admin: {
    type: 'admin',
    routes: [
      {
        method: 'GET',
        path: '/settings',
        handler: 'settings.getSettings',
        config: {
          policies: ['admin::isAuthenticatedAdmin'],
        },
      },
    ],
  },
};

控制器

// server/src/controllers/item.ts
import type { Core } from '@strapi/strapi';

const controller = ({ strapi }: { strapi: Core.Strapi }) => ({
  async findMany(ctx) {
    const items = await strapi
      .documents('plugin::my-plugin.item')
      .findMany({
        filters: ctx.query.filters,
        populate: ctx.query.populate,
      });

    return { data: items };
  },

  async create(ctx) {
    const { data } = ctx.request.body;

    const item = await strapi
      .documents('plugin::my-plugin.item')
      .create({ data });

    return { data: item };
  },
});

export default controller;

服务

// server/src/services/item.ts
import type { Core } from '@strapi/strapi';

const service = ({ strapi }: { strapi: Core.Strapi }) => ({
  async findPublished(locale = 'en') {
    return strapi.documents('plugin::my-plugin.item').findMany({
      status: 'published',
      locale,
    });
  },

  async publishItem(documentId: string) {
    return strapi.documents('plugin::my-plugin.item').publish({
      documentId,
    });
  },
});

export default service;

内容类型架构

{
  "kind": "collectionType",
  "collectionName": "items",
  "info": {
    "singularName": "item",
    "pluralName": "items",
    "displayName": "Item"
  },
  "options": {
    "draftAndPublish": true
  },
  "attributes": {
    "title": {
      "type": "string",
      "required": true
    },
    "slug": {
      "type": "uid",
      "targetField": "title"
    },
    "content": {
      "type": "richtext"
    },
    "author": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "plugin::users-permissions.user"
    }
  }
}

内容类型 UID 格式

始终使用正确的 UID 格式:

类型格式示例
API 内容类型api::singular.singularapi::article.article
插件内容类型plugin::plugin-name.typeplugin::my-plugin.item
用户plugin::users-permissions.user-

管理面板组件

基本管理页面
// admin/src/pages/HomePage.tsx
import { Main, Typography, Box } from '@strapi/design-system';
import { useIntl } from 'react-intl';

const HomePage = () => {
  const { formatMessage } = useIntl();

  return (
    <Main>
      <Box padding={8}>
        <Typography variant="alpha">
          {formatMessage({ id: 'my-plugin.title', defaultMessage: 'My Plugin' })}
        </Typography>
      </Box>
    </Main>
  );
};

export default HomePage;
插件注册
// admin/src/index.tsx
import { getTranslation } from './utils/getTranslation';
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';

export default {
  register(app: any) {
    app.addMenuLink({
      to: `plugins/${PLUGIN_ID}`,
      icon: PluginIcon,
      intlLabel: {
        id: `${PLUGIN_ID}.plugin.name`,
        defaultMessage: 'My Plugin',
      },
      Component: async () => import('./pages/App'),
    });

    app.registerPlugin({
      id: PLUGIN_ID,
      initializer: Initializer,
      isReady: false,
      name: PLUGIN_ID,
    });
  },

  async registerTrads({ locales }: { locales: string[] }) {
    return Promise.all(
      locales.map(async (locale) => {
        try {
          const { default: data } = await import(`./translations/${locale}.json`);
          return { data, locale };
        } catch {
          return { data: {}, locale };
        }
      })
    );
  },
};

策略

// server/src/policies/is-owner.ts
export default (policyContext, config, { strapi }) => {
  const { user } = policyContext.state;

  if (!user) {
    return false;
  }

  // Custom ownership logic
  return true;
};

应避免的常见反模式

反模式正确做法
使用 Entity Service使用 Document Service API
使用 strapi.query() 执行 CRUD使用 strapi.documents()
UID 硬编码使用常量或配置
控制器中没有错误处理使用 try-catch 包装,并使用 ctx.throw
直接查询数据库使用带筛选条件的 Document Service
跳过策略始终实现授权

故障排除指南

问题解决方案
插件未加载检查 package.json 是否包含 strapi.kind: "plugin"
路由返回 404验证路由类型(content-api 或 admin)和处理器路径
权限被拒绝在 Settings > Roles 中配置权限
管理面板空白检查 admin/src/index.tsx 的导出和 React 错误
TypeScript 错误运行 strapi ts:generate-types
构建失败在插件中运行 npm run build,检查是否存在导入错误

开发命令

# Create new plugin
npx @strapi/sdk-plugin@latest init my-plugin

# Build plugin
cd my-plugin && npm run build

# Watch mode for development
npm run watch

# Link plugin for local development
npm run watch:link

# Verify plugin structure
npx @strapi/sdk-plugin@latest verify

插件架构最佳实践

基于 strapi-community/plugin-todo 参考实现。

设计原则
  1. 工厂模式:使用 Strapi 的 factories.createCoreService()、factories.createCoreController() 和 factories.createCoreRouter() 执行标准 CRUD 操作。
  2. 服务层模式:业务逻辑放在服务中,控制器负责委托给服务。
  3. 管理端/内容 API 分离:路由分为管理面板和公开 API。
  4. 内容管理器集成:使用注入区域为现有内容管理器视图添加 UI。
  5. 使用 React Query 处理数据:使用 @tanstack/react-query 获取管理面板数据并执行变更操作。
推荐的插件结构(plugin-todo 模式)
plugin-name/
├── package.json                 # Plugin metadata with exports
├── admin/
│   └── src/
│       ├── index.ts             # Admin registration & bootstrap
│       ├── pluginId.ts          # Plugin ID constant
│       ├── components/
│       │   ├── Initializer.tsx  # Plugin initialization
│       │   └── [Component].tsx  # UI components
│       ├── utils/               # Helper utilities
│       └── translations/
│           └── en.json
└── server/
    └── src/
        ├── index.ts             # Server exports aggregator
        ├── content-types/
        │   ├── index.ts
        │   └── [type-name]/
        │       ├── index.ts
        │       └── schema.json
        ├── controllers/
        │   ├── index.ts
        │   └── [name].ts
        ├── services/
        │   ├── index.ts
        │   └── [name].ts
        └── routes/
            ├── index.ts         # Route aggregator
            ├── admin/
            │   ├── index.ts     # Admin routes with custom endpoints
            │   └── [name].ts    # Core router for CRUD
            └── content-api/
                └── index.ts     # Public API routes
使用现代导出配置的 Package.json
{
  "name": "@strapi-community/plugin-todo",
  "version": "1.0.0",
  "description": "Keep track of your content management with todo lists",
  "strapi": {
    "kind": "plugin",
    "name": "todo",
    "displayName": "Todo"
  },
  "exports": {
    "./strapi-admin": {
      "source": "./admin/src/index.ts",
      "import": "./dist/admin/index.mjs",
      "require": "./dist/admin/index.js"
    },
    "./strapi-server": {
      "source": "./server/src/index.ts",
      "import": "./dist/server/index.mjs",
      "require": "./dist/server/index.js"
    }
  },
  "dependencies": {
    "@tanstack/react-query": "^5.0.0"
  },
  "peerDependencies": {
    "@strapi/strapi": "^5.0.0",
    "@strapi/design-system": "^2.0.0",
    "react": "^17.0.0 || ^18.0.0"
  }
}
服务端索引模式
// server/src/index.ts
import controllers from './controllers';
import routes from './routes';
import services from './services';
import contentTypes from './content-types';

export default {
  controllers,
  routes,
  services,
  contentTypes,
};
基于工厂的服务
// server/src/services/task.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreService('plugin::todo.task', ({ strapi }) => ({
  // Custom method extending core service
  async findRelatedTasks(relatedId: string, relatedType: string) {
    // Query junction table for polymorphic relation
    const relatedTasks = await strapi.db
      .query('tasks_related_mph')
      .findMany({
        where: { related_id: relatedId, related_type: relatedType },
      });

    const taskIds = relatedTasks.map((t) => t.task_id);

    // Fetch full task documents
    return strapi.documents('plugin::todo.task').findMany({
      filters: { id: { $in: taskIds } },
    });
  },
}));
基于工厂的控制器
// server/src/controllers/task.ts
import { factories } from '@strapi/strapi';

export default factories.createCoreController('plugin::todo.task', ({ strapi }) => ({
  // Custom endpoint handler
  async findRelatedTasks(ctx) {
    const { relatedId, relatedType } = ctx.params;

    const tasks = await strapi
      .service('plugin::todo.task')
      .findRelatedTasks(relatedId, relatedType);

    ctx.body = tasks;
  },
}));
使用核心路由器组织路由
// server/src/routes/index.ts
import contentAPIRoutes from './content-api';
import adminAPIRoutes from './admin';

const routes = {
  'content-api': contentAPIRoutes,
  admin: adminAPIRoutes,
};

export default routes;
// server/src/routes/admin/task.ts - Core CRUD routes
import { factories } from '@strapi/strapi';

export default factories.createCoreRouter('plugin::todo.task');
// server/src/routes/admin/index.ts - Custom + Core routes
import task from './task';

export default () => ({
  type: 'admin',
  routes: [
    // Spread core CRUD routes
    ...task.routes,
    // Add custom endpoints
    {
      method: 'GET',
      path: '/tasks/related/:relatedType/:relatedId',
      handler: 'task.findRelatedTasks',
    },
  ],
});
隐藏的插件内容类型(内部使用)
{
  "kind": "collectionType",
  "collectionName": "tasks",
  "info": {
    "singularName": "task",
    "pluralName": "tasks",
    "displayName": "Task"
  },
  "options": {
    "draftAndPublish": false
  },
  "pluginOptions": {
    "content-manager": { "visible": false },
    "content-type-builder": { "visible": false }
  },
  "attributes": {
    "name": { "type": "text" },
    "done": { "type": "boolean" },
    "related": {
      "type": "relation",
      "relation": "morphToMany"
    }
  }
}
集成 Content Manager 的管理面板
// admin/src/index.ts
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { TodoPanel } from './components/TodoPanel';

export default {
  register(app: any) {
    app.registerPlugin({
      id: PLUGIN_ID,
      initializer: Initializer,
      isReady: false,
      name: PLUGIN_ID,
    });
  },

  bootstrap(app: any) {
    // Inject panel into Content Manager edit view
    app.getPlugin('content-manager').injectComponent('editView', 'right-links', {
      name: 'todo-panel',
      Component: TodoPanel,
    });
  },

  async registerTrads({ locales }: { locales: string[] }) {
    return Promise.all(
      locales.map(async (locale) => {
        try {
          const { default: data } = await import(`./translations/${locale}.json`);
          return { data, locale };
        } catch {
          return { data: {}, locale };
        }
      })
    );
  },
};
管理组件的 React Query 模式
// admin/src/components/TodoPanel.tsx
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/strapi/admin';
import { TextButton, Plus } from '@strapi/design-system';
import { TaskList } from './TaskList';
import { TodoModal } from './TodoModal';

const queryClient = new QueryClient();

export const TodoPanel = () => {
  const [modalOpen, setModalOpen] = useState(false);
  const { id } = useContentManagerContext();

  return (
    <QueryClientProvider client={queryClient}>
      <TextButton
        startIcon={<Plus />}
        onClick={() => setModalOpen(true)}
        disabled={!id}
      >
        Add todo
      </TextButton>

      {id && (
        <>
          <TodoModal open={modalOpen} setOpen={setModalOpen} />
          <TaskList />
        </>
      )}
    </QueryClientProvider>
  );
};
使用 useFetchClient 获取数据
// admin/src/components/TaskList.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useFetchClient, unstable_useContentManagerContext } from '@strapi/strapi/admin';
import { Checkbox } from '@strapi/design-system';

export const TaskList = () => {
  const { get, put } = useFetchClient();
  const { slug, id } = unstable_useContentManagerContext();
  const queryClient = useQueryClient();

  const { data: tasks } = useQuery({
    queryKey: ['tasks', slug, id],
    queryFn: () => get(`/todo/tasks/related/${slug}/${id}`).then((res) => res.data),
  });

  const toggleMutation = useMutation({
    mutationFn: (task: any) =>
      put(`/todo/tasks/${task.documentId}`, { data: { done: !task.done } }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks', slug, id] }),
  });

  return (
    <ul>
      {tasks?.map((task: any) => (
        <li key={task.id}>
          <Checkbox
            checked={task.done}
            onCheckedChange={() => toggleMutation.mutate(task)}
          >
            {task.name}
          </Checkbox>
        </li>
      ))}
    </ul>
  );
};
最佳实践清单

服务端:

  • 使用 factories.createCoreService() 实现标准 CRUD
  • 使用 factories.createCoreController() 并添加自定义方法
  • 使用 factories.createCoreRouter() 自动创建 CRUD 路由
  • 将路由拆分到 admin/ 和 content-api/ 目录
  • 在 Content Manager UI 中隐藏内部内容类型

管理面板:

  • 使用 QueryClientProvider 提供 React Query 上下文
  • 使用 useFetchClient() 调用 API
  • 使用 unstable_useContentManagerContext() 获取当前实体信息
  • 使用 app.getPlugin('content-manager').injectComponent() 集成 CM
  • 使用 registerTrads() 支持翻译

内容类型:

  • 对多态关系使用 morphToMany
  • 对内部类型设置 pluginOptions.content-manager.visible: false
  • 使用单数名称(task 而非 tasks)

详细模式请参阅 patterns.md。 实际示例请参阅 examples.md。

评论

支持 Markdown