首页自定义

(v5.13.0+)

页面摘要: 管理面板首页默认显示内容和个人资料小组件,并支持通过 app.widgets.register API 添加自定义小组件。

首页是 Strapi 管理面板的着陆页。默认情况下,它通过 6 个默认小组件提供内容的概览:

  • 最近编辑的条目:显示最近修改的内容条目,包括其内容类型、状态和更新时间。
  • 最近发布的条目:显示最近发布的内容条目,方便你快速访问和管理已发布内容。
  • 个人资料:显示你的个人资料简要信息,包括姓名、电子邮件地址和角色。
  • 条目:显示草稿与发布的条目总数。
  • 项目统计:显示有关条目、内容类型、语言环境、资源等的统计数据。
  • 部署:显示一个 立即部署(Deploy Now)按钮,链接到 Strapi Cloud 以部署你的项目。该小组件仅在本地的开发环境中显示,当应用在生产环境运行时会被隐藏。

带有默认小组件的首页

这些默认小组件目前无法移除,但你可以通过创建自己的小组件来自定义首页。

NOTE

如果你最近创建了一个 Strapi 项目,首页还可能在小组件上方显示一个引导式导览(前提是你尚未跳过它)(详见 管理面板 文档)。

添加自定义小组件

要添加自定义小组件,你可以:

  • 从 Marketplace 安装插件
  • 或创建并注册你自己的小组件

本页将介绍如何创建并注册你的小组件。

注册自定义小组件

要注册小组件,请使用 app.widgets.register():

INFO

本页的示例将涵盖通过插件注册小组件。如果你在应用的全局 register() 生命周期方法中注册小组件,大部分代码都可复用,只是你不应传入 pluginId 属性。

JavaScript

import pluginId from './pluginId';
import MyWidgetIcon from './components/MyWidgetIcon';

export default {
  register(app) {
    // Register the plugin itself
    app.registerPlugin({
      id: pluginId,
      name: 'My Plugin',
    });
    
    // Register a widget for the Homepage
    app.widgets.register({
      icon: MyWidgetIcon,
      title: {
        id: `${pluginId}.widget.title`,
        defaultMessage: 'My Widget',
      },
      component: async () => {
        const component = await import('./components/MyWidget');
        return component.default;
      },
      /**
       * Use this instead if you used a named export for your component
       */
      // component: async () => {
      //   const { Component } = await import('./components/MyWidget');
      //   return Component;
      // },
      id: 'my-custom-widget',
      pluginId: pluginId,
    });
  },
  
  bootstrap() {},
  // ...
};

TypeScript

import pluginId from './pluginId';
import MyWidgetIcon from './components/MyWidgetIcon';
import type { StrapiApp } from '@strapi/admin/strapi-admin';

export default {
  register(app: StrapiApp) {
    // Register the plugin itself
    app.registerPlugin({
      id: pluginId,
      name: 'My Plugin',
    });
    
    // Register a widget for the Homepage
    app.widgets.register({
      icon: MyWidgetIcon,
      title: {
        id: `${pluginId}.widget.title`,
        defaultMessage: 'My Widget',
      },
      component: async () => {
        const component = await import('./components/MyWidget');
        return component.default;
      },
      /**
       * Use this instead if you used a named export for your component
       */
      // component: async () => {
      //   const { Component } = await import('./components/MyWidget');
      //   return Component;
      // },
      id: 'my-custom-widget',
      pluginId: pluginId,
    });
  },
  
  bootstrap() {},
  // ...
};
该 API 需要 Strapi 5.13 及以上版本

app.widgets.register API 仅适用于 Strapi 5.13 及以上版本。尝试在更旧版本的 Strapi 上调用该 API 会导致管理面板崩溃。 插件开发者若希望注册小组件,应:

  • 在其插件的 package.json 中将 ^5.13.0 设为 @strapi/strapi 的 peerDependency。该 peer 依赖用于 Marketplace 的兼容性检查。

  • 或在调用前检查该 API 是否存在:

    if ('widgets' in app) {
      // proceed with the registration
    }
    

如果插件的全部用途就是注册小组件,推荐使用 peerDependency 方式。如果插件想添加一个小组件,但其大部分功能在其他地方,则第二种方式更合理。

小组件 API 参考

app.widgets.register() 方法可以接受单个小组件配置对象,或一组配置对象。每个小组件配置对象可以接受以下属性:

属性类型说明必填
iconReact.ComponentType显示在小组件标题旁的图标组件是
titleMessageDescriptor支持翻译的小组件标题是
component() => Promise<React.ComponentType>返回小组件组件的异步函数是
idstring小组件的唯一标识符是
linkObject添加到小组件的可选链接(参见链接对象属性)否
pluginIdstring注册该小组件的插件 ID否
permissionsPermission[]查看小组件所需的权限否

链接对象属性:

如果你要为小组件添加链接(例如导航到详情视图),可以提供一个 link 对象,其属性如下:

属性类型说明必填
labelMessageDescriptor链接显示的文本是
hrefstring链接应导航到的 URL是

创建小组件

小组件组件应设计为以紧凑且信息丰富的方式展示内容。

以下是如何实现基础小组件组件:

JavaScript

import React, { useState, useEffect } from 'react';
import { Widget } from '@strapi/admin/strapi-admin';

const MyWidget = () => {
  const [loading, setLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Fetch your data here
    const fetchData = async () => {
      try {
        // Replace with your actual API call
        const response = await fetch('/my-plugin/data');
        const result = await response.json();
        
        setData(result);
        setLoading(false);
      } catch (err) {
        setError(err);
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  if (loading) {
    return <Widget.Loading />;
  }

  if (error) {
    return <Widget.Error />;
  }

  if (!data || data.length === 0) {
    return <Widget.NoData />;
  }

  return (
    <div>
      {/* Your widget content here */}
      <ul>
        {data.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default MyWidget;

TypeScript

import React, { useState, useEffect } from 'react';
import { Widget } from '@strapi/admin/strapi-admin';

interface DataItem {
  id: number;
  name: string;
}

const MyWidget: React.FC = () => {
  const [loading, setLoading] = useState<boolean>(true);
  const [data, setData] = useState<DataItem[] | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    // Fetch your data here
    const fetchData = async () => {
      try {
        // Replace with your actual API call
        const response = await fetch('/my-plugin/data');
        const result = await response.json();
        
        setData(result);
        setLoading(false);
      } catch (err) {
        setError(err instanceof Error ? err : new Error(String(err)));
        setLoading(false);
      }
    };

    fetchData();
  }, []);

  if (loading) {
    return <Widget.Loading />;
  }

  if (error) {
    return <Widget.Error />;
  }

  if (!data || data.length === 0) {
    return <Widget.NoData />;
  }

  return (
    <div>
      {/* Your widget content here */}
      <ul>
        {data.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default MyWidget;
TIP

为简单起见,以下示例直接在 useEffect hook 中发起数据获取。虽然这种方式可用于演示,但未必符合生产环境的最佳实践。

如需更稳健的方案,请考虑 React 文档 中推荐的替代方式。如果你想集成数据获取库,我们推荐使用 TanStackQuery。

数据管理:

Rendering and Data management

上图中的绿色方框代表用户 React 组件(来自 API 中的 widget.component)被渲染的区域。你可以在该方框内渲染任意内容。但该方框之外的内容则由 Strapi 渲染,以此确保管理面板整体设计的一致性。API 中提供的 icon、title 和 link(可选)属性用于显示小组件。

小组件辅助组件参考

Strapi 提供了若干辅助组件,以保持各小组件一致的用户体验:

组件说明用法
Widget.Loading显示加载 spinner 与提示数据获取时
Widget.Error显示错误状态发生错误时
Widget.NoData无可用数据时显示小组件无数据可显示时
Widget.NoPermissions当用户缺少所需权限时显示用户无法访问小组件时

这些组件有助于在不同小组件之间保持一致的视觉风格。 你可以不带子元素渲染这些组件以使用默认文案:<Widget.Error /> 也可以传入子元素来覆盖默认文案并指定你自己的措辞:<Widget.Error>你的自定义错误消息</Widget.Error>。

示例:添加内容指标小组件

以下示例完整展示了如何创建一个内容指标小组件,用于显示 Strapi 应用中每个内容类型的条目数量。

最终效果在你的管理面板 首页中将如下所示:

个人资料页面的账单标签页

该小组件会显示 Strapi 在你安装时提供 --example 标志后自动生成的示例内容类型的计数(详见 CLI 安装选项)。

可通过以下方式将该小组件添加到 Strapi:

  1. 创建一个 "content-metrics" 插件(详见 插件创建 文档)
  2. 复用下面提供的代码示例。
TIP

如果你更喜欢动手实践,可以复用以下 CodeSandbox 链接。

JavaScript

以下文件注册了插件和小组件:

import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { PluginIcon } from './components/PluginIcon';
import { Stethoscope } from '@strapi/icons'

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

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

    // Registers the widget
    app.widgets.register({
      icon: Stethoscope,
      title: {
        id: `${PLUGIN_ID}.widget.metrics.title`, 
        defaultMessage: 'Content Metrics',
      },
      component: async () => {
        const component = await import('./components/MetricsWidget');
        return component.default;
      },
      id: 'content-metrics',
      pluginId: PLUGIN_ID, 
    });
  },

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

  bootstrap() {},
};

以下文件定义了小组件的组件及其逻辑。它接入了我们为插件创建的特定控制器和路由:

import React, { useState, useEffect } from 'react';
import { Table, Tbody, Tr, Td, Typography, Box } from '@strapi/design-system';
import { Widget } from '@strapi/admin/strapi-admin'

const MetricsWidget = () => {
  const [loading, setLoading] = useState(true);
  const [metrics, setMetrics] = useState(null);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchMetrics = async () => {
      try {
        const response = await fetch('/api/content-metrics/count');
        const data = await response.json();

        console.log("data:", data);
        
        const formattedData = {};
        
        if (data && typeof data === 'object') {
          Object.keys(data).forEach(key => {
            const value = data[key];
            formattedData[key] = typeof value === 'number' ? value : String(value);
          });
        }
        
        setMetrics(formattedData);
        setLoading(false);
      } catch (err) {
        console.error(err);
        setError(err.message || 'An error occurred');
        setLoading(false);
      }
    };
    
    fetchMetrics();
  }, []);
  
  if (loading) {
    return (
      <Widget.Loading />
    );
  }
  
  if (error) {
    return (
      <Widget.Error />
    );
  }
  
  if (!metrics || Object.keys(metrics).length === 0) {
    return <Widget.NoData>No content types found</Widget.NoData>;
  }
  
  return (
    <Table>
      <Tbody>
        {Object.entries(metrics).map(([contentType, count], index) => (
          <Tr key={index}>
            <Td>
              <Typography variant="omega">{String(contentType)}</Typography>
            </Td>
            <Td>
              <Typography variant="omega" fontWeight="bold">{String(count)}</Typography>
            </Td>
          </Tr>
        ))}
      </Tbody>
    </Table>
  );
};

export default MetricsWidget;

以下文件定义了一个统计所有内容类型的自定义控制器:

'use strict';
module.exports = ({ strapi }) => ({
  async getContentCounts(ctx) {
    try {
      // Get all content types
      const contentTypes = Object.keys(strapi.contentTypes)
        .filter(uid => uid.startsWith('api::'))
        .reduce((acc, uid) => {
          const contentType = strapi.contentTypes[uid];
          acc[contentType.info.displayName || uid] = 0;
          return acc;
        }, {});
      
      // Count entities for each content type
      for (const [name, _] of Object.entries(contentTypes)) {
        const uid = Object.keys(strapi.contentTypes)
          .find(key => 
            strapi.contentTypes[key].info.displayName === name || key === name
          );
          
        if (uid) {
          // Using the count() method from the Document Service API
          const count = await strapi.documents(uid).count();
          contentTypes[name] = count;
        }
      }
      
      ctx.body = contentTypes;
    } catch (err) {
      ctx.throw(500, err);
    }
  }
});

以下文件确保 metrics 控制器可通过自定义的 /count 路由访问:

export default {
  'content-api': {
    type: 'content-api',
    routes: [
      {
        method: 'GET',
        path: '/count',
        handler: 'metrics.getContentCounts',
        config: {
          policies: [],
        },
      },
    ],
  },
};

TypeScript

以下文件注册了插件和小组件:

import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { PluginIcon } from './components/PluginIcon';
import { Stethoscope } from '@strapi/icons'

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

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

    // Registers the widget
    app.widgets.register({
      icon: Stethoscope,
      title: {
        id: `${PLUGIN_ID}.widget.metrics.title`, 
        defaultMessage: 'Content Metrics',
      },
      component: async () => {
        const component = await import('./components/MetricsWidget');
        return component.default;
      },
      id: 'content-metrics',
      pluginId: PLUGIN_ID, 
    });
  },

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

  bootstrap() {},
};

以下文件定义了小组件的组件及其逻辑。它接入了我们为插件创建的特定控制器和路由:

import React, { useState, useEffect } from 'react';
import { Table, Tbody, Tr, Td, Typography, Box } from '@strapi/design-system';
import { Widget } from '@strapi/admin/strapi-admin'

const MetricsWidget = () => {
  const [loading, setLoading] = useState(true);
  const [metrics, setMetrics] = useState(null);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchMetrics = async () => {
      try {
        const response = await fetch('/api/content-metrics/count');
        const data = await response.json();

        console.log("data:", data);
        
        const formattedData = {};
        
        if (data && typeof data === 'object') {
          Object.keys(data).forEach(key => {
            const value = data[key];
            formattedData[key] = typeof value === 'number' ? value : String(value);
          });
        }
        
        setMetrics(formattedData);
        setLoading(false);
      } catch (err) {
        console.error(err);
        setError(err.message || 'An error occurred');
        setLoading(false);
      }
    };
    
    fetchMetrics();
  }, []);
  
  if (loading) {
    return (
      <Widget.Loading />
    );
  }
  
  if (error) {
    return (
      <Widget.Error />
    );
  }
  
  if (!metrics || Object.keys(metrics).length === 0) {
    return <Widget.NoData>No content types found</Widget.NoData>;
  }
  
  return (
    <Table>
      <Tbody>
        {Object.entries(metrics).map(([contentType, count], index) => (
          <Tr key={index}>
            <Td>
              <Typography variant="omega">{String(contentType)}</Typography>
            </Td>
            <Td>
              <Typography variant="omega" fontWeight="bold">{String(count)}</Typography>
            </Td>
          </Tr>
        ))}
      </Tbody>
    </Table>
  );
};

export default MetricsWidget;

以下文件定义了一个统计所有内容类型的自定义控制器:

'use strict';
module.exports = ({ strapi }) => ({
  async getContentCounts(ctx) {
    try {
      // Get all content types
      const contentTypes = Object.keys(strapi.contentTypes)
        .filter(uid => uid.startsWith('api::'))
        .reduce((acc, uid) => {
          const contentType = strapi.contentTypes[uid];
          acc[contentType.info.displayName || uid] = 0;
          return acc;
        }, {});
      
      // Count entities for each content type using Document Service
      for (const [name, _] of Object.entries(contentTypes)) {
        const uid = Object.keys(strapi.contentTypes)
          .find(key => 
            strapi.contentTypes[key].info.displayName === name || key === name
          );
          
        if (uid) {
          // Using the count() method from Document Service instead of strapi.db.query
          const count = await strapi.documents(uid).count();
          contentTypes[name] = count;
        }
      }
      
      ctx.body = contentTypes;
    } catch (err) {
      ctx.throw(500, err);
    }
  }
});

以下文件确保 metrics 控制器可通过自定义的 /count 路由访问:

export default {
  'content-api': {
    type: 'content-api',
    routes: [
      {
        method: 'GET',
        path: '/count',
        handler: 'metrics.getContentCounts',
        config: {
          policies: [],
        },
      },
    ],
  },
};