界面和样式

NocoBase 应用使用 shadcn/ui 组件和 Tailwind CSS 编写界面。基础组件放在 client/components/ui/,应用共享组件放在 client/components/,页面及其专用组件放在 client/pages/ 中对应的页面目录。从 NocoBase UI Library 安装的业务组件同样放在 client/components/,例如模板预装的 PageContainer、PageHeader 和路由浮层组件;登录页用到的 auth-forms、auth-methods、auth-split-layout 这类区块则放在 client/extensions/nocobase-<item>/。

普通界面样式使用主题变量。这样同一套组件可以适配浅色主题、深色主题和其他主题预设。

使用 shadcn/ui 组件

先检查 client/components/ui/ 中是否已有需要的组件。如果没有,在应用根目录运行 shadcn CLI:

pnpm exec shadcn add card
pnpm exec shadcn add table badge

CLI 会把组件源码写入 client/components/ui/。一个组件依赖的其他组件已经存在、且内容和 registry 不同时(模板自带的组件都是这样,比如翻译过文案的 dialog),CLI 会询问是否覆盖,这时回答“否”,保留已有文件。模板的 components.json 已将 ui 别名配置为 @/components/ui,因此可以使用 @/components/ui/<component> 导入组件。

添加组件前,可以先查看源码、文档或 registry 中的其他组件:

pnpm exec shadcn view card
pnpm exec shadcn docs card
pnpm exec shadcn search @shadcn -q dialog

search 的第一个参数是 registry 名称,搜索关键词通过 -q 传入。

已有 shadcn/ui 组件时,直接使用它的实现,不要重新手写同名组件或从其他项目复制一份。焦点管理、键盘操作和 ARIA 属性通常已经在基础组件中处理好。

组合业务组件

client/components/ui/ 只放基础组件。组合后的业务组件如果供多个页面复用,放在 client/components/;只服务于某个页面及其子页面的组件,放在该页面目录。下面以应用共享的订单摘要组件为例:

// client/components/order-summary.tsx
import type { ReactElement } from 'react';
import { useTranslation } from '@nocobase/i18n/client';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

interface Order {
  readonly reference: string;
}

interface OrderSummaryProps {
  readonly order: Order;
}

export function OrderSummary({ order }: OrderSummaryProps): ReactElement {
  const { t } = useTranslation();

  return (
    <Card>
      <CardHeader>
        <CardTitle>{order.reference}</CardTitle>
      </CardHeader>
      <CardContent className='text-muted-foreground'>
        {t('orders.summary')}
      </CardContent>
    </Card>
  );
}

@/ 指向应用的 client/ 目录。业务组件只负责自己的界面和行为,基础组件的通用交互继续由 shadcn/ui 组件提供。

页面标题和操作区

模板提供 PageHeader,统一排列页面标题、描述和操作区。title 必填,description 和 actions 可选:

import { useTranslation } from '@nocobase/i18n/client';
import { Link } from 'react-router';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';

export function OrdersHeader() {
  const { t } = useTranslation();
  return (
    <PageHeader
      title={t('orders.title')}
      description={t('orders.description')}
      actions={
        <Button nativeButton={false} render={<Link to='create' />}>
          {t('orders.create')}
        </Button>
      }
    />
  );
}

示例假设当前订单页面已声明 create 子路由,并添加了相应翻译。PageHeader 内部渲染 h1,外层容器、间距和面包屑由页面负责。子页面与面包屑的组合示例见页面和菜单。

使用主题变量

颜色、字体、字号、间距、圆角和阴影优先使用语义化的 Tailwind class:

推荐使用避免使用
bg-background、bg-card、bg-mutedbg-white、bg-gray-50
text-foreground、text-muted-foregroundtext-black、text-gray-600
border-border、border-inputborder-gray-200
bg-primary、text-primary-foregroundbg-blue-600、text-white
bg-destructive、text-destructivebg-red-500

这些 class 会引用应用主题中的 CSS 变量。例如,bg-card 在不同主题下会使用各自的 --card 值,组件不需要为深色模式重复写一套颜色。

// client/components/order-form.tsx
import { useTranslation } from '@nocobase/i18n/client';
import type { ReactElement } from 'react';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

export function OrderForm(): ReactElement {
  const { t } = useTranslation();

  return (
    <form className='space-y-4 rounded-lg border border-border bg-card p-6 text-card-foreground'>
      <h2 className='font-heading text-lg'>{t('orders.new')}</h2>
      <div className='space-y-2'>
        <Label htmlFor='reference'>{t('orders.reference')}</Label>
        <Input id='reference' placeholder='ORD-0001' />
      </div>
      <div className='flex gap-2'>
        <Button type='submit'>{t('common.save')}</Button>
        <Button type='button' variant='secondary'>
          {t('common.cancel')}
        </Button>
      </div>
    </form>
  );
}

字体使用 font-sans、font-heading 和 font-mono:

  • 正文使用 font-sans text-base
  • 标题使用语义化的 h1 到 h6,或显式添加 font-heading
  • code、pre、kbd 和 samp 使用 font-mono

间距、尺寸和圆角优先使用标准刻度,比如 p-4、gap-2、h-8 和 rounded-lg。图片尺寸、视口限制和圆形图标等确实需要固定值时,可以保留固定值。

不要用 dark: 为普通颜色单独补一套样式。只在主题变量无法表达需求时使用它;经常需要使用 dark: 通常说明样式中混入了硬编码颜色。

主题预设的变量定义在 client/theme/themes/*.css,修改整套外观时应该调整主题变量,而不是逐个页面覆盖样式。完整的主题配置方式见主题,变量清单见主题变量。

使用图标

应用使用 lucide-react 图标。图标尺寸使用 Tailwind 的 size-* 刻度,使图标和旁边的文字保持一致:

import { Settings } from 'lucide-react';
import type { ReactElement } from 'react';

export function SettingsIcon(): ReactElement {
  return <Settings className='size-4' aria-hidden='true' />;
}

正文旁的图标通常使用 size-4,需要更醒目时使用 size-5。只有图片、图表等有明确尺寸要求的内容才使用固定尺寸。

处理加载、空和错误状态

需要请求数据的界面,通常都要处理加载中、无数据和请求失败三种状态。应用提供了共享的 Loading 组件:

// client/pages/orders.tsx
import { ApiClientError, useApiClient } from '@nocobase/app-client';
import { useTranslation } from '@nocobase/i18n/client';
import { useEffect, useState, type ReactElement } from 'react';

import { Loading } from '@/components/loading';

interface Order {
  readonly id: string;
  readonly reference: string;
}

interface OrdersResponse {
  readonly data: Order[];
  readonly meta: { page: number; pageSize: number; total: number };
}

export default function OrdersPage(): ReactElement {
  const api = useApiClient();
  const { t } = useTranslation();
  const [orders, setOrders] = useState<Order[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string>();

  useEffect(() => {
    void api
      .request<OrdersResponse>({ path: 'orders' })
      .then((response) => setOrders(response.data))
      .catch((cause: unknown) => {
        // Never show cause.message to users; map the status or reason to translated text.
        setError(
          cause instanceof ApiClientError && cause.status === 403
            ? t('orders.forbidden')
            : t('orders.loadFailed'),
        );
      })
      .finally(() => setIsLoading(false));
  }, [api, t]);

  if (isLoading) {
    return <Loading label={t('orders.loading')} />;
  }

  if (error) {
    return <p className='p-6 text-sm text-destructive'>{error}</p>;
  }

  if (orders.length === 0) {
    return (
      <p className='p-6 text-sm text-muted-foreground'>{t('orders.empty')}</p>
    );
  }

  return (
    <ul className='space-y-2 p-6'>
      {orders.map((order) => (
        <li
          key={order.id}
          className='rounded-lg border border-border bg-card p-4 text-card-foreground'
        >
          {order.reference}
        </li>
      ))}
    </ul>
  );
}

api.request({ path: 'orders' }) 假设应用已经提供了对应的服务端接口,它按 HTTP API 规范返回 { data, meta }。请求失败时抛出的 ApiClientError 带有 status 和 reason,界面按它们选择自己的翻译文案,不要直接显示 message。加载反馈要放在正在加载的界面区域内;如果内容位于对话框或抽屉中,就在对话框或抽屉内部显示加载状态。

文字和多语言

页面中的用户可见文字使用翻译 key,并将对应翻译添加到 client/locales/。具体用法见多语言。

相关链接

  • 页面和菜单 — 声明页面路由、菜单和访问控制
  • 多语言 — 为界面文案添加翻译
  • 主题变量 — 查看颜色、字体、字号、间距、圆角和阴影
  • 主题 — 新增和修改主题预设