跳转到正文

PATTERNS

界面组合

用已有组件组织设置、账户面板与 AI 工作台,查看真实源码和交互边界。

这些组合由真实组件实现,演示状态保存在当前页面。设置与账户示例不连接业务后端,AI 工作台使用静态消息 fixture。

个人设置表单

正在准备交互组合…

组合使用的组件

本地表单演示;onSave 由业务接入,未连接持久化服务。

查看组合源码
recipes/settings.tsx
"use client";

import { useState } from "react";
import { Button } from "@cwa-design/react";
import { Card, CardActions, CardContent, CardDescription, CardTitle } from "@cwa-design/react";
import { Dialog } from "@cwa-design/react";
import { Field } from "@cwa-design/react";
import { Input } from "@cwa-design/react";
import { Stack } from "@cwa-design/react";
import { Switch } from "@cwa-design/react";
import { createToastManager, ToastProvider } from "@cwa-design/react";
import { RecipeScope as CwaProvider } from "./recipe-scope";

/** Settings recipe 的可见状态(业务侧持有;此处为组件内演示状态)。 */
export interface SettingsValues {
  displayName: string;
  workspace: string;
  notifications: boolean;
}

export interface SettingsRecipeProps {
  initialValues?: Partial<SettingsValues>;
  onSave?: (values: SettingsValues) => void;
}

/**
 * Settings recipe(T31):Field/Input/Select 语义用 Input+原生 select 替代前,
 * 用 Field+Input+Switch+Button+Dialog+Toast 的真实组合。
 * 原生 <form> 提交;Toast 由 createToastManager 驱动,不连任何模型 API。
 */
export function SettingsRecipe({ initialValues, onSave }: SettingsRecipeProps) {
  const [manager] = useState(() => createToastManager());
  const [values, setValues] = useState<SettingsValues>({
    displayName: initialValues?.displayName ?? "CWA Developer",
    workspace: initialValues?.workspace ?? "默认工作区",
    notifications: initialValues?.notifications ?? true,
  });
  const [confirmOpen, setConfirmOpen] = useState(false);

  return (
    <CwaProvider>
      <ToastProvider toastManager={manager}>
        <form
          onSubmit={(event) => {
            event.preventDefault();
            onSave?.(values);
            manager.add({
              title: "已保存设置",
              description: onSave ? "已提交给应用" : "已保存在当前演示中",
            });
          }}
          style={{ maxWidth: "32rem" }}
        >
          <Card>
            <CardTitle>个人设置</CardTitle>
            <CardDescription>编辑显示名称与通知偏好;保存操作可由应用接入。</CardDescription>
            <CardContent>
              <Stack gap={4}>
                <Field label="显示名称" description="用于工作台中的个人资料">
                  <Input
                    name="displayName"
                    value={values.displayName}
                    onChange={(e) => setValues((v) => ({ ...v, displayName: e.target.value }))}
                  />
                </Field>
                <Field label="工作区名称">
                  <Input
                    name="workspace"
                    value={values.workspace}
                    onChange={(e) => setValues((v) => ({ ...v, workspace: e.target.value }))}
                  />
                </Field>
                <Switch
                  name="notifications"
                  checked={values.notifications}
                  onCheckedChange={(checked) =>
                    setValues((v) => ({ ...v, notifications: checked === true }))
                  }
                >
                  接收产品通知
                </Switch>
              </Stack>
            </CardContent>
            <CardActions>
              <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
                <Dialog.Trigger render={<Button variant="secondary">重置…</Button>} />
                <Dialog.Content material="solid">
                  <Dialog.Title>重置为默认值?</Dialog.Title>
                  <Dialog.Description>
                    未保存的修改将丢失。此操作可在保存前撤销。
                  </Dialog.Description>
                  <Dialog.Close
                    render={
                      <Button
                        variant="secondary"
                        onClick={() =>
                          setValues({
                            displayName: "CWA Developer",
                            workspace: "默认工作区",
                            notifications: true,
                          })
                        }
                      >
                        确认重置
                      </Button>
                    }
                  />
                </Dialog.Content>
              </Dialog>
              <Button variant="primary" type="submit">
                保存设置
              </Button>
            </CardActions>
          </Card>
        </form>
      </ToastProvider>
    </CwaProvider>
  );
}
recipes/recipe-scope.tsx
import type { ReactNode } from "react";
import { CwaProvider, useOptionalCwaContext } from "@cwa-design/react";

export function RecipeScope({ children }: { children: ReactNode }) {
  const parent = useOptionalCwaContext();
  return parent ? children : <CwaProvider>{children}</CwaProvider>;
}
单独查看此组合 →

账户面板

正在准备交互组合…

组合使用的组件

活动与账单为静态 fixture;业务操作可通过回调接入,默认只给本地反馈。

查看组合源码
recipes/account-panel.tsx
"use client";

import { useState } from "react";
import { Avatar } from "@cwa-design/react";
import { Badge } from "@cwa-design/react";
import { Button } from "@cwa-design/react";
import { Card, CardActions, CardContent, CardDescription, CardTitle } from "@cwa-design/react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
} from "@cwa-design/react";
import { Skeleton } from "@cwa-design/react";
import { Stack } from "@cwa-design/react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@cwa-design/react";
import { Text } from "@cwa-design/react";
import { RecipeScope as CwaProvider } from "./recipe-scope";

export interface AccountPanelRecipeProps {
  /** 静态 fixture;真实业务接入时由调用方传入。 */
  name?: string;
  plan?: "Pro" | "Team" | "Free";
  loading?: boolean;
  onSignOut?: () => void;
  onAccountSettings?: () => void;
  onWorkspaceChange?: () => void;
  onManageSubscription?: () => void;
}

const mockActivity: Array<{ label: string; time: string }> = [
  { label: "更新了 API 密钥", time: "2 小时前" },
  { label: "邀请新成员", time: "昨天" },
  { label: "升级到 Pro 计划", time: "上周" },
];

/** AccountPanel recipe(T31):Avatar/Badge/Card/Tabs/DropdownMenu/Skeleton/Alert 组合,静态 fixture。 */
export function AccountPanelRecipe({
  name = "李雷",
  plan = "Pro",
  loading = false,
  onSignOut,
  onAccountSettings,
  onWorkspaceChange,
  onManageSubscription,
}: AccountPanelRecipeProps) {
  const [feedback, setFeedback] = useState("");
  const requestAction = (label: string, callback?: () => void) => {
    callback?.();
    setFeedback(callback ? `已请求${label}。` : `${label}演示:由宿主应用接入此操作。`);
  };
  return (
    <CwaProvider>
      <div style={{ width: "24rem", maxWidth: "100%", minWidth: 0 }}>
        <Card>
          <CardTitle>
            <Stack direction="row" gap={3} style={{ alignItems: "center" }}>
              {loading ? (
                <Skeleton width={40} height={40} radius={9999} />
              ) : (
                <Avatar fallback={name.slice(0, 1)} />
              )}
              <Stack gap={1}>
                <Text>{name}</Text>
                <Badge tone="accent">{plan}</Badge>
              </Stack>
              <span style={{ marginInlineStart: "auto" }}>
                <DropdownMenu>
                  <DropdownMenu.Trigger
                    render={
                      <Button variant="ghost" aria-label="账户操作">
                        ⋯
                      </Button>
                    }
                  />
                  <DropdownMenuContent>
                    <DropdownMenuItem onClick={() => requestAction("账户设置", onAccountSettings)}>
                      账户设置
                    </DropdownMenuItem>
                    <DropdownMenuItem
                      onClick={() => requestAction("切换工作区", onWorkspaceChange)}
                    >
                      切换工作区
                    </DropdownMenuItem>
                    <DropdownMenuItem
                      destructive
                      onClick={() => requestAction("退出登录", onSignOut)}
                    >
                      退出登录
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
              </span>
            </Stack>
          </CardTitle>
          <Tabs defaultValue="activity">
            <TabsList aria-label="账户信息">
              <TabsTab value="activity">最近活动</TabsTab>
              <TabsTab value="billing">账单</TabsTab>
            </TabsList>
            <TabsPanel value="activity">
              <Stack gap={3}>
                {loading
                  ? mockActivity.map((item) => (
                      <Skeleton key={item.label} width="100%" height="1.25rem" />
                    ))
                  : mockActivity.map((item) => (
                      <Stack
                        key={item.label}
                        direction="row"
                        gap={3}
                        style={{ justifyContent: "space-between", flexWrap: "wrap" }}
                      >
                        <Text>{item.label}</Text>
                        <Text variant="caption" tone="muted">
                          {item.time}
                        </Text>
                      </Stack>
                    ))}
              </Stack>
            </TabsPanel>
            <TabsPanel value="billing">
              {loading ? (
                <Skeleton width="100%" height="4rem" />
              ) : (
                <Text variant="caption" tone="muted">
                  下一期账单:2026-11-01 · ¥128/月
                </Text>
              )}
            </TabsPanel>
          </Tabs>
          <CardActions>
            <Button
              variant="secondary"
              onClick={() => requestAction("管理订阅", onManageSubscription)}
            >
              管理订阅
            </Button>
          </CardActions>
          <CardContent>
            <Text variant="caption" tone="muted" role="status">
              {feedback}
            </Text>
          </CardContent>
        </Card>
      </div>
    </CwaProvider>
  );
}

export function AccountSkeletonRecipe() {
  return (
    <Card>
      <CardTitle>加载中…</CardTitle>
      <CardContent>
        <Stack gap={3}>
          <Skeleton width="60%" height="1.25rem" />
          <Skeleton width="100%" height="1rem" />
          <Skeleton width="80%" height="1rem" />
        </Stack>
      </CardContent>
      <CardDescription>减少动态偏好下闪烁自动关闭,占位几何保持不变。</CardDescription>
    </Card>
  );
}
recipes/recipe-scope.tsx
import type { ReactNode } from "react";
import { CwaProvider, useOptionalCwaContext } from "@cwa-design/react";

export function RecipeScope({ children }: { children: ReactNode }) {
  const parent = useOptionalCwaContext();
  return parent ? children : <CwaProvider>{children}</CwaProvider>;
}
单独查看此组合 →

AI 工作台界面组合

正在准备交互组合…

组合使用的组件

消息与工具记录为静态 fixture;未连接模型服务。

发送与附件选择提供本地演示,可通过回调接入业务;没有文件上传服务。

查看组合源码
recipes/ai-workspace.tsx
"use client";

import { useRef, useState } from "react";
import { Badge } from "@cwa-design/react";
import { Button } from "@cwa-design/react";
import { Card, CardContent } from "@cwa-design/react";
import { Field } from "@cwa-design/react";
import { Popover, PopoverContent } from "@cwa-design/react";
import { Sheet } from "@cwa-design/react";
import { Stack } from "@cwa-design/react";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@cwa-design/react";
import { Text } from "@cwa-design/react";
import { Textarea } from "@cwa-design/react";
import { RecipeScope as CwaProvider } from "./recipe-scope";

export interface AiWorkspaceRecipeProps {
  /** 静态消息 fixture;不连任何模型 API,无模型 key。 */
  messages?: Array<{
    id: string;
    role: "user" | "assistant" | "tool";
    text: string;
    status?: "ok" | "running" | "error";
  }>;
  /** 提交给宿主应用;缺省只将消息保存在当前演示页面。 */
  onSend?: (text: string) => void;
  /** 本地文件选择回调;组件本身不上传文件。 */
  onAttach?: (files: File[]) => void;
}

const defaultMessages: NonNullable<AiWorkspaceRecipeProps["messages"]> = [
  { id: "m1", role: "user", text: "帮我总结这份规格文档的验收标准。" },
  {
    id: "m2",
    role: "tool",
    text: "retrieve(spec.pdf) → 12 段命中",
    status: "ok",
  },
  {
    id: "m3",
    role: "assistant",
    text: "规格文档共列出 5 条验收标准:安装链路、焦点管理、中断动效、材质对比度、tarball 消费。",
  },
];

function StatusBadge({ status }: { status?: "ok" | "running" | "error" }) {
  if (!status) return null;
  const tone = status === "ok" ? "success" : status === "running" ? "warning" : "danger";
  const label = status === "ok" ? "已完成" : status === "running" ? "进行中" : "失败";
  return <Badge tone={tone}>{`工具 ${label}`}</Badge>;
}

/** AIWorkspace recipe(T31):Sheet/Popover/Tabs/Text/Button 组合;消息为静态 fixture,无模型 key。 */
export function AiWorkspaceRecipe({
  messages = defaultMessages,
  onSend,
  onAttach,
}: AiWorkspaceRecipeProps) {
  const [draft, setDraft] = useState("");
  const [localMessages, setLocalMessages] = useState<
    NonNullable<AiWorkspaceRecipeProps["messages"]>
  >([]);
  const [attachments, setAttachments] = useState<File[]>([]);
  const [feedback, setFeedback] = useState("");
  const fileInput = useRef<HTMLInputElement>(null);
  const messageSequence = useRef(0);
  return (
    <CwaProvider>
      <div style={{ width: "30rem", maxWidth: "100%", minWidth: 0 }}>
        <Card>
          <CardContent>
            <Stack gap={4}>
              <Stack
                direction="row"
                gap={3}
                style={{ alignItems: "center", justifyContent: "space-between", flexWrap: "wrap" }}
              >
                <Text style={{ fontWeight: 590 }}>AI 工作台</Text>
                <Stack direction="row" gap={2}>
                  <Popover>
                    <Popover.Trigger
                      render={
                        <Button variant="ghost" aria-label="会话信息">
                          ⓘ
                        </Button>
                      }
                    />
                    <PopoverContent aria-label="会话信息">
                      <Text variant="caption" tone="muted">
                        初始消息是演示数据。输入与附件保存在当前页面;模型连接由宿主应用提供。
                      </Text>
                    </PopoverContent>
                  </Popover>
                  <Sheet>
                    <Sheet.Trigger render={<Button variant="secondary">会话详情</Button>} />
                    <Sheet.Content>
                      <Sheet.Title className="cwa-design-sheet__title">会话详情</Sheet.Title>
                      <Sheet.Description>工具调用与消息元数据(fixture 数据)。</Sheet.Description>
                      <Tabs defaultValue="meta">
                        <TabsList aria-label="会话详情分区">
                          <TabsTab value="meta">元数据</TabsTab>
                          <TabsTab value="tools">工具</TabsTab>
                        </TabsList>
                        <TabsPanel value="meta">
                          <Text variant="caption" tone="muted">
                            会话 ID:fixture-0001 · 模型:无(静态演示)
                          </Text>
                        </TabsPanel>
                        <TabsPanel value="tools">
                          <Text variant="caption" tone="muted">
                            retrieve(spec.pdf):已完成 · 12 段命中
                          </Text>
                        </TabsPanel>
                      </Tabs>
                      <Sheet.Close render={<Button variant="secondary">关闭</Button>} />
                    </Sheet.Content>
                  </Sheet>
                </Stack>
              </Stack>
              {[...messages, ...localMessages].map((message) => (
                <Stack
                  key={message.id}
                  gap={1}
                  style={{
                    alignSelf: message.role === "user" ? "flex-end" : "flex-start",
                    maxWidth: "85%",
                  }}
                >
                  <Text variant="caption" tone="muted">
                    {message.role === "user" ? "你" : message.role === "tool" ? "工具" : "助手"}
                  </Text>
                  <div
                    style={{
                      padding: "var(--cwa-design-space-3)",
                      borderRadius: "var(--cwa-design-radius-control)",
                      background:
                        message.role === "user"
                          ? "color-mix(in srgb, var(--cwa-design-color-accent) 12%, transparent)"
                          : "var(--cwa-design-color-surface)",
                      border: "1px solid var(--cwa-design-color-border-subtle)",
                      overflowWrap: "anywhere",
                    }}
                  >
                    <Text>{message.text}</Text>
                  </div>
                  {message.status ? <StatusBadge status={message.status} /> : null}
                </Stack>
              ))}
              <form
                onSubmit={(event) => {
                  event.preventDefault();
                  const text = draft.trim();
                  if (!text) return;
                  onSend?.(text);
                  messageSequence.current += 1;
                  setLocalMessages((previous) => [
                    ...previous,
                    { id: `local-${messageSequence.current}`, role: "user", text },
                  ]);
                  setDraft("");
                  setFeedback(
                    onSend ? "消息已提交给应用。" : "消息已保存在本地演示中,未调用模型。",
                  );
                }}
              >
                <Stack gap={3}>
                  <Field label="消息" description="发送后追加到当前会话;演示不会生成模型回复。">
                    <Textarea
                      value={draft}
                      onChange={(event) => setDraft(event.target.value)}
                      rows={3}
                      placeholder="输入消息…"
                    />
                  </Field>
                  <input
                    ref={fileInput}
                    type="file"
                    multiple
                    className="cwa-design-visually-hidden"
                    tabIndex={-1}
                    aria-label="选择本地附件"
                    onChange={(event) => {
                      const files = Array.from(event.target.files ?? []);
                      setAttachments(files);
                      onAttach?.(files);
                      setFeedback(
                        files.length ? `已选择 ${files.length} 个本地附件。` : "已清除附件。",
                      );
                    }}
                  />
                  {attachments.length ? (
                    <Text variant="caption" tone="muted" style={{ overflowWrap: "anywhere" }}>
                      本地附件:{attachments.map((file) => file.name).join("、")}
                    </Text>
                  ) : null}
                  <Stack
                    direction="row"
                    gap={2}
                    style={{ justifyContent: "flex-end", flexWrap: "wrap" }}
                  >
                    <Button variant="ghost" onClick={() => fileInput.current?.click()}>
                      附件
                    </Button>
                    {attachments.length ? (
                      <Button
                        variant="ghost"
                        onClick={() => {
                          setAttachments([]);
                          if (fileInput.current) fileInput.current.value = "";
                          onAttach?.([]);
                          setFeedback("已清除附件。");
                        }}
                      >
                        清除附件
                      </Button>
                    ) : null}
                    <Button variant="primary" type="submit" disabled={!draft.trim()}>
                      发送
                    </Button>
                  </Stack>
                  <Text variant="caption" tone="muted" role="status">
                    {feedback}
                  </Text>
                </Stack>
              </form>
            </Stack>
          </CardContent>
        </Card>
      </div>
    </CwaProvider>
  );
}
recipes/recipe-scope.tsx
import type { ReactNode } from "react";
import { CwaProvider, useOptionalCwaContext } from "@cwa-design/react";

export function RecipeScope({ children }: { children: ReactNode }) {
  const parent = useOptionalCwaContext();
  return parent ? children : <CwaProvider>{children}</CwaProvider>;
}
单独查看此组合 →

媒体工具栏可读配方

正在准备交互组合…

组合使用的组件

媒体背景为样式化占位(无真实照片依赖);接入时用应用实际媒体验证对比度。

Clear 仅在媒体与主题方向一致且文字/必要图标有实色底面时使用;混合亮度请用 Thick/Solid。

查看组合源码
recipes/media-toolbar.tsx
"use client";

import { useState } from "react";
import { Button } from "@cwa-design/react";
import { IconButton } from "@cwa-design/react";
import { Surface } from "@cwa-design/react";
import { Text } from "@cwa-design/react";
import { RecipeScope as CwaProvider } from "./recipe-scope";

/**
 * F08:媒体工具栏可读配方。按媒体亮/暗/混合选择前景保护策略:
 * - Regular(默认):正文/控件直接可读,适合大多数媒体;
 * - Clear(显式启用):仅当媒体与主题方向一致且文字/必要图标有实色底面时使用;
 * - Thick/Solid:复杂混合亮度或要求稳定阅读底面时的回退。
 * 前景保护通过局部实色底面(图标配色 + 局部 Surface)实现,不要求切换站点主题;
 * 文字/图标保持不透明;blur 固定;动效只用 transform/opacity。
 */
export type MediaToolbarTone = "regular" | "clear" | "thick" | "solid";

export interface MediaToolbarRecipeProps {
  /** 初始材质;默认 regular(可读性最稳)。 */
  initialMaterial?: MediaToolbarTone;
  /** 媒体亮度:bright/dark/mixed;用于文案与默认策略说明。 */
  media?: "bright" | "dark" | "mixed";
}

const MATERIAL_BY_TONE = {
  regular: "glass",
  clear: "glass-clear",
  thick: "frosted",
  solid: "solid",
} as const;

/** 媒体工具栏可读配方(F08)。 */
export function MediaToolbarRecipe({
  initialMaterial = "regular",
  media = "mixed",
}: MediaToolbarRecipeProps) {
  const [material, setMaterial] = useState<MediaToolbarTone>(initialMaterial);
  const surfaceMaterial = MATERIAL_BY_TONE[material];
  return (
    <CwaProvider>
      <div
        style={{
          position: "relative",
          minHeight: 280,
          borderRadius: 24,
          overflow: "hidden",
          display: "grid",
          alignContent: "center",
          justifyItems: "center",
          padding: 24,
          background:
            media === "bright"
              ? "linear-gradient(135deg, #bfe0f2, #f3ead8)"
              : media === "dark"
                ? "linear-gradient(135deg, #101b2c, #1d2c46)"
                : "linear-gradient(110deg, #172434 50%, #ebeddf 50%)",
        }}
      >
        <div aria-hidden="true" style={{ position: "absolute", inset: 0 }}>
          <div
            style={{
              position: "absolute",
              top: "18%",
              left: "6%",
              width: 200,
              height: 120,
              borderRadius: 999,
              background: media === "dark" ? "#0c1526" : "#ffffff",
              opacity: 0.6,
            }}
          />
          <div
            style={{
              position: "absolute",
              bottom: "12%",
              right: "8%",
              width: 260,
              height: 140,
              borderRadius: 24,
              background: media === "dark" ? "#22354f" : "#9fc3d8",
              opacity: 0.55,
            }}
          />
        </div>
        <Surface
          material={surfaceMaterial}
          style={{
            position: "relative",
            borderRadius: 20,
            padding: "10px 14px",
            display: "flex",
            gap: 12,
            alignItems: "center",
            maxWidth: "100%",
          }}
        >
          <span
            aria-hidden="true"
            style={{
              width: 39,
              height: 39,
              flexShrink: 0,
              display: "grid",
              placeItems: "center",
              borderRadius: 9,
              fontSize: 18,
              // F08:图标使用实色底面(局部保护),非文本对比按相邻底色测量。
              color: "#f6ead6",
              background: "linear-gradient(135deg, #346b7f, #1f3f40)",
            }}
          >
            ▶
          </span>
          <div style={{ flex: 1, minWidth: 0 }}>
            <strong style={{ fontSize: 14 }}>
              {media === "dark" ? "夜色山谷" : media === "bright" ? "高山晨光" : "明暗之间"}
            </strong>
            <Text variant="caption" tone="muted" as="p" style={{ margin: 0 }}>
              {material === "clear"
                ? "Clear:媒体方向一致 + 图标实色底面"
                : material === "thick"
                  ? "Thick:混合亮度的稳妥回退"
                  : material === "solid"
                    ? "Solid:最长阅读时间的底面"
                    : "Regular:默认可读"}
            </Text>
          </div>
          {/* 必要图标:♡ 有实色底面(局部 Surface 保护),非文本对比 ≥3:1。 */}
          <Surface
            material="frosted"
            style={{ borderRadius: 999, display: "grid", placeItems: "center" }}
          >
            <IconButton label="收藏" variant="ghost" size="sm">
              ♡
            </IconButton>
          </Surface>
          <Button
            variant="secondary"
            size="sm"
            onClick={() =>
              setMaterial((m) =>
                m === "regular"
                  ? "clear"
                  : m === "clear"
                    ? "thick"
                    : m === "thick"
                      ? "solid"
                      : "regular",
              )
            }
          >
            切换材质
          </Button>
        </Surface>
        <Text
          variant="caption"
          tone="muted"
          as="p"
          style={{
            position: "relative",
            margin: "14px 0 0",
            textAlign: "center",
            background: "rgba(255,255,255,0.72)",
            borderRadius: 8,
            padding: "2px 10px",
          }}
        >
          F08 配方:媒体亮暗与前景角色决定材质与保护,不需要切换站点主题。
        </Text>
      </div>
    </CwaProvider>
  );
}
recipes/recipe-scope.tsx
import type { ReactNode } from "react";
import { CwaProvider, useOptionalCwaContext } from "@cwa-design/react";

export function RecipeScope({ children }: { children: ReactNode }) {
  const parent = useOptionalCwaContext();
  return parent ? children : <CwaProvider>{children}</CwaProvider>;
}
单独查看此组合 →