跳转到正文

PATTERN

账户面板

可运行的 account-panel 组合,与真实源码和组件契约对应。

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

账户面板

正在准备交互组合…

组合使用的组件

活动与账单为静态 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>;
}