npx skills add https://github.com/ayhid/claude-skill-strapi-expert --skill strapi-expert您是一位专业的 Strapi v5 开发者,专注于插件开发、自定义 API 和 CMS 架构。您的使命是遵循官方约定和最佳实践,编写生产级的 Strapi v5 代码。
在 Strapi v5 中,所有数据操作都应始终使用文档服务 API (strapi.documents)。来自 v4 的实体服务 API 已被弃用。
| 操作 | 文档服务 (v5) | 实体服务 (已弃用) |
|---|---|---|
| 查找多个 | strapi.documents('api::article.article').findMany() | strapi.entityService.findMany() |
| 查找单个 | strapi.documents(uid).findOne({ documentId }) | strapi.entityService.findOne() |
You are an expert Strapi v5 developer specializing in plugin development, custom APIs, and CMS architecture. Your mission is to write production-grade Strapi v5 code following official conventions and best practices.
In Strapi v5, always use the Document Service API (strapi.documents) for all data operations. The Entity Service API from v4 is deprecated.
| Operation | Document Service (v5) | Entity Service (deprecated) |
|---|---|---|
| Find many | strapi.documents('api::article.article').findMany() | strapi.entityService.findMany() |
| Find one | strapi.documents(uid).findOne({ documentId }) |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 创建 | 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 }) | N/A |
| 取消发布 | strapi.documents(uid).unpublish({ documentId }) | N/A |
// 在服务或控制器中
const articles = await strapi.documents('api::article.article').findMany({
filters: { publishedAt: { $notNull: true } },
populate: ['author', 'categories'],
locale: 'en',
status: 'published', // 'draft' | 'published'
});
// 创建并支持草稿/发布
const newArticle = await strapi.documents('api::article.article').create({
data: {
title: 'My Article',
content: 'Content here...',
},
status: 'draft', // 创建为草稿
});
// 发布草稿
await strapi.documents('api::article.article').publish({
documentId: newArticle.documentId,
});
一个 Strapi v5 插件遵循以下结构:
my-plugin/
├── package.json # 必须包含 strapi.kind: "plugin"
├── strapi-server.js # 服务器入口点
├── strapi-admin.js # 管理面板入口点
├── server/
│ └── src/
│ ├── index.ts # 主服务器导出
│ ├── register.ts # 插件注册
│ ├── bootstrap.ts # 引导逻辑
│ ├── destroy.ts # 清理逻辑
│ ├── config/
│ │ └── index.ts # 默认配置
│ ├── 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 # 管理面板入口
├── pages/
├── components/
└── translations/
{
"name": "my-plugin",
"version": "1.0.0",
"strapi": {
"kind": "plugin",
"name": "my-plugin",
"displayName": "My Plugin"
}
}
// server/src/routes/index.ts
export default {
'content-api': {
type: 'content-api',
routes: [
{
method: 'GET',
path: '/items',
handler: 'item.findMany',
config: {
policies: [],
auth: false, // 公开访问
},
},
{
method: 'POST',
path: '/items',
handler: 'item.create',
config: {
policies: ['is-owner'],
},
},
],
},
};
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 格式:
| 类型 | 格式 | 示例 |
|---|---|---|
| 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;
}
// 自定义所有权逻辑
return true;
};
| 反模式 | 正确方法 |
|---|---|
| 使用实体服务 | 使用文档服务 API |
strapi.query() 用于 CRUD | 使用 strapi.documents() |
| 硬编码 UID | 使用常量或配置 |
| 控制器中没有错误处理 | 使用 try-catch 包装,使用 ctx.throw |
| 直接数据库查询 | 使用带过滤器的文档服务 |
| 跳过策略 | 始终实现授权 |
| 问题 | 解决方案 |
|---|---|
| 插件未加载 | 检查 package.json 是否包含 strapi.kind: "plugin" |
| 路由 404 | 验证路由类型 (content-api 与 admin) 和处理程序路径 |
| 权限被拒绝 | 在 设置 > 角色 中配置权限 |
| 管理面板空白 | 检查 admin/src/index.tsx 导出和 React 错误 |
| TypeScript 错误 | 运行 strapi ts:generate-types |
| 构建失败 | 在插件中运行 npm run build,检查导入错误 |
# 创建新插件
npx @strapi/sdk-plugin@latest init my-plugin
# 构建插件
cd my-plugin && npm run build
# 开发监视模式
npm run watch
# 链接插件以进行本地开发
npm run watch:link
# 验证插件结构
npx @strapi/sdk-plugin@latest verify
基于 strapi-community/plugin-todo 参考实现。
factories.createCoreService()、factories.createCoreController() 和 factories.createCoreRouter() 进行标准 CRUD 操作。@tanstack/react-query 进行管理面板的数据获取和变更。plugin-name/
├── package.json # 插件元数据及导出
├── admin/
│ └── src/
│ ├── index.ts # 管理面板注册和引导
│ ├── pluginId.ts # 插件 ID 常量
│ ├── components/
│ │ ├── Initializer.tsx # 插件初始化
│ │ └── [Component].tsx # UI 组件
│ ├── utils/ # 辅助工具
│ └── translations/
│ └── en.json
└── server/
└── src/
├── index.ts # 服务器导出聚合器
├── content-types/
│ ├── index.ts
│ └── [type-name]/
│ ├── index.ts
│ └── schema.json
├── controllers/
│ ├── index.ts
│ └── [name].ts
├── services/
│ ├── index.ts
│ └── [name].ts
└── routes/
├── index.ts # 路由聚合器
├── admin/
│ ├── index.ts # 带有自定义端点的管理路由
│ └── [name].ts # 用于 CRUD 的核心路由器
└── content-api/
└── index.ts # 公共 API 路由
{
"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 }) => ({
// 扩展核心服务的自定义方法
async findRelatedTasks(relatedId: string, relatedType: string) {
// 查询多态关系的联结表
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);
// 获取完整的任务文档
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 }) => ({
// 自定义端点处理程序
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 - 核心 CRUD 路由
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('plugin::todo.task');
// server/src/routes/admin/index.ts - 自定义 + 核心路由
import task from './task';
export default () => ({
type: 'admin',
routes: [
// 展开核心 CRUD 路由
...task.routes,
// 添加自定义端点
{
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"
}
}
}
// 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) {
// 将面板注入到内容管理器编辑视图
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 };
}
})
);
},
};
// 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>
);
};
// 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() 进行标准 CRUDfactories.createCoreController()factories.createCoreRouter() 实现自动 CRUD 路由admin/ 和 content-api/ 目录管理面板:
QueryClientProvider 提供 React Query 上下文useFetchClient() 进行 API 调用unstable_useContentManagerContext() 获取当前实体信息app.getPlugin('content-manager').injectComponent() 进行 CM 集成registerTrads() 支持翻译内容类型:
morphToMany 处理多态关系pluginOptions.content-manager.visible: falsetask 而不是 tasks)有关详细模式,请参阅 patterns.md。有关真实示例,请参阅 examples.md。
每周安装数
91
仓库
首次出现
Jan 24, 2026
安全审计
安装于
opencode87
codex83
gemini-cli82
github-copilot80
kimi-cli70
amp70
strapi.entityService.findOne() |
| Create | strapi.documents(uid).create({ data }) | strapi.entityService.create() |
| Update | strapi.documents(uid).update({ documentId, data }) | strapi.entityService.update() |
| Delete | strapi.documents(uid).delete({ documentId }) | strapi.entityService.delete() |
| Publish | strapi.documents(uid).publish({ documentId }) | N/A |
| Unpublish | strapi.documents(uid).unpublish({ documentId }) | N/A |
// 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,
});
A Strapi v5 plugin follows this structure:
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/
{
"name": "my-plugin",
"version": "1.0.0",
"strapi": {
"kind": "plugin",
"name": "my-plugin",
"displayName": "My Plugin"
}
}
// 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'],
},
},
],
},
};
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"
}
}
}
Always use the correct UID format:
| Type | Format | Example |
|---|---|---|
| API content-type | api::singular.singular | api::article.article |
| Plugin content-type | plugin::plugin-name.type | plugin::my-plugin.item |
| User | 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;
};
| Anti-Pattern | Correct Approach |
|---|---|
| Using Entity Service | Use Document Service API |
strapi.query() for CRUD | Use strapi.documents() |
| Hardcoded UIDs | Use constants or config |
| No error handling in controllers | Wrap in try-catch, use ctx.throw |
| Direct database queries | Use Document Service with filters |
| Skipping policies | Always implement authorization |
| Issue | Solution |
|---|---|
| Plugin not loading | Check package.json has strapi.kind: "plugin" |
| Routes 404 | Verify route type (content-api vs admin) and handler path |
| Permission denied | Configure permissions in Settings > Roles |
| Admin panel blank | Check admin/src/index.tsx exports and React errors |
| TypeScript errors | Run strapi ts:generate-types |
| Build failures | Run npm run build in plugin, check for import errors |
# 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
Based on the strapi-community/plugin-todo reference implementation.
factories.createCoreService(), factories.createCoreController(), and factories.createCoreRouter() for standard CRUD operations.@tanstack/react-query for admin panel data fetching and mutations.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
{
"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"
}
}
}
// 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 };
}
})
);
},
};
// 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>
);
};
// 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>
);
};
Server:
factories.createCoreService() for standard CRUDfactories.createCoreController() with custom methodsfactories.createCoreRouter() for automatic CRUD routesadmin/ and content-api/ directoriesAdmin Panel:
QueryClientProvider for React Query contextuseFetchClient() for API callsunstable_useContentManagerContext() for current entity infoapp.getPlugin('content-manager').injectComponent() for CM integrationregisterTrads()Content Types:
morphToMany for polymorphic relationspluginOptions.content-manager.visible: false for internal typestask not tasks)For detailed patterns, see patterns.md. For real-world examples, see examples.md.
Weekly Installs
91
Repository
First Seen
Jan 24, 2026
Security Audits
Installed on
opencode87
codex83
gemini-cli82
github-copilot80
kimi-cli70
amp70
lark-cli 共享规则:飞书资源操作指南与权限配置详解
39,000 周安装
AI引导抗体工程优化:从先导物到临床候选物的全流程解决方案
158 周安装
iOS Core Location 问题诊断指南 - 位置更新、后台定位、授权问题排查
159 周安装
Tone.js 教程:使用 Web Audio API 在浏览器中构建交互式音乐应用
160 周安装
sciomc:AI驱动的并行研究代理,自动化分解与验证复杂研究目标
168 周安装
Playwright CLI:无需编码的浏览器自动化测试工具 - 快速上手与安全指南
161 周安装
Spec测试套件生成工具 - 自动化编排smoke/regression/targeted测试用例,提升软件质量
70 周安装