关于 strapi-expert
面向开发者的 Strapi v5 参考 skill,涵盖插件、API 和 CMS 功能开发。它强调使用 Document Service API,并提供服务端与管理面板架构、路由、控制器和服务的实现模式。
主要功能
- 01Strapi v5 Document Service 指引
- 02插件结构与注册模式
- 03管理端和 Content API 路由模式
- 04控制器与服务示例
- 05常见问题与故障排查
适用场景
- 01开发 Strapi v5 插件
- 02添加自定义 API 路由和控制器
- 03设计插件管理面板组件
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ mkshahzad77/ claude-skill-strapi-expert --skill strapi-expert安装后在 Claude Code 中输入/strapi-expert确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
CONTRIBUTING.md.gitignoreREADME.mdexamples.mdpatterns.mdLICENSE详细介绍
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.singular | api::article.article |
| 插件内容类型 | plugin::plugin-name.type | plugin::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 参考实现。
设计原则
- 工厂模式:使用 Strapi 的
factories.createCoreService()、factories.createCoreController()和factories.createCoreRouter()执行标准 CRUD 操作。 - 服务层模式:业务逻辑放在服务中,控制器负责委托给服务。
- 管理端/内容 API 分离:路由分为管理面板和公开 API。
- 内容管理器集成:使用注入区域为现有内容管理器视图添加 UI。
- 使用 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