Card

# Card

# 概念

Card(卡片)是 AutumnBox 首页侧边栏中的仪表盘小组件。每个 Card 占据侧边栏的一个区域,用于展示摘要信息、实时状态或快捷操作按钮。

与 App 不同,Card 不需要用户主动打开——只要插件被加载,其注册的 Card 就会始终显示在首页侧边栏中。Card 没有独立的标签页,也不支持设备选择弹窗,更像是一个始终可见的 widget。

# Card 在 UI 中的位置

┌──────────────────────────────────────────────────────┐
│  AutumnBox                                           │
├──────────┬───────────────────────────────────────────┤
│ 侧边栏    │  标签页内容区                                │
│          │                                           │
│ [设备列表] │  ┌─────────────────────────────────────┐  │
│          │  │                                     │  │
│ ── Cards ── │  │           App 内容                  │  │
│ ┌────────┐ │  │                                     │  │
│ │电池状态 │ │  │                                     │  │
│ │ 87% ⚡ │ │  │                                     │  │
│ └────────┘ │  │                                     │  │
│ ┌────────┐ │  │                                     │  │
│ │设备状态 │ │  │                                     │  │
│ │CPU 12% │ │  │                                     │  │
│ └────────┘ │  └─────────────────────────────────────┘  │
│ ┌────────┐ │                                           │
│ │快速重启 │ │                                           │
│ │[系统][R]│ │                                           │
│ └────────┘ │                                           │
└──────────┴───────────────────────────────────────────┘

Card 位于侧边栏的设备列表下方,以垂直列表的方式排列。每个 Card 被包裹在一个 Ant Design <Card> 容器中,由宿主应用统一管理样式和错误边界。

# 创建一个 Card

# 目录结构

Card 文件放在插件的 src/cards/ 目录下:

my-plugin/
└── src/
    ├── cards/
    │   ├── BatteryCard.tsx          # 单文件方式
    │   ├── DeviceStatusCard.tsx
    │   └── RebootCard/
    │       └── index.tsx            # 目录方式(适合复杂 Card)
    └── apps/
        └── ...

# 最小示例

// src/cards/HelloCard.tsx
import type { AutumnCard } from '@autumnbox/sdk';

const HelloCardView: React.FC = () => {
  return <div style={{ textAlign: 'center', padding: 8 }}>Hello, AutumnBox!</div>;
};

export const HelloCard: AutumnCard = {
  id: 'hello',
  name: 'card.name.hello',
  component: HelloCardView,
};

就这么多。autumnbox-sdk build 会自动扫描 src/cards/,找到 export const HelloCard,在 src/__entry__.ts 中生成注册代码。插件加载后,Card 自动显示在首页侧边栏。

# 完整类型定义

AutumnCard 是一个判别联合类型(discriminated union),支持 React 组件模式和自定义 DOM 挂载模式:

/** Card 基础字段 */
interface ICardBase {
  /** Card 唯一标识符 */
  id: string;

  /** 显示名称(支持 i18n key) */
  name: string;
}

/**
 * AutumnCard — 统一的 Card 定义类型。
 * 提供 `component`(React 模式)或 `mount`(自定义 DOM 模式),二选一。
 */
type AutumnCard = ICardBase &
  (
    | { component: React.FC; mount?: undefined }
    | { mount: (container: HTMLElement) => () => void; component?: undefined }
  );

这是一个排他联合:你要么提供 component(React 组件),要么提供 mount(手动 DOM 挂载函数),不能同时提供两者。TypeScript 编译器会在编译时检查这一约束。

# 字段参考表

属性 类型 必须 说明
id string ✅ 唯一标识符。在同一插件内不可重复,建议使用 kebab-case(如 "battery"、"device-status")
name string ✅ 显示名称。支持 i18n key(如 "card.name.battery"),会经过翻译系统解析
component React.FC ✅* React 组件。不接收任何 props,通过 hooks 获取数据
mount (container: HTMLElement) => () => void ✅* 自定义 DOM 挂载函数。接收容器元素,返回 dispose 函数用于清理

* component 和 mount 二选一,必须提供其中之一。

与 App 相比,Card 没有以下属性:

  • 没有 icon — Card 标题由宿主容器管理
  • 没有 singleton — Card 始终只有一个实例
  • 没有 shallSelectAdbDevice — Card 不绑定特定设备
  • 没有 tags — Card 无需分类筛选

# React 模式(推荐)

绝大多数 Card 应使用 React 模式。Card 组件不接收 props,所有数据通过 hooks 从服务获取。

# 完整示例:电池状态卡片

以下示例展示了一个功能完整的电池状态 Card,包含设备遍历、轮询数据、错误处理和响应式状态:

// src/cards/BatteryCard.tsx
import type { AutumnCard } from '@autumnbox/sdk';
import type { AdbDeviceHandle } from '@autumnbox/sdk/core';

import { ThunderboltOutlined } from '@ant-design/icons';
import { useService, useServiceState } from '@autumnbox/sdk/app';
import { DevicesService, ShellService } from '@autumnbox/sdk/core';
import { List, Typography } from 'antd';
import { useEffect, useState } from 'react';

const { Text } = Typography;

interface BatteryInfo {
  level: number;
  status: 'charging' | 'discharging' | 'full';
  temperature: number;
}

function parseBatteryDump(output: string): BatteryInfo | null {
  const levelMatch = /level:\s*(\d+)/i.exec(output);
  const statusMatch = /status:\s*(\d+)/i.exec(output);
  const tempMatch = /temperature:\s*(\d+)/i.exec(output);
  if (!levelMatch || !statusMatch || !tempMatch) return null;

  const statusCode = Number(statusMatch[1]);
  const status = statusCode === 2 ? 'charging' : statusCode === 5 ? 'full' : 'discharging';

  return {
    level: Number(levelMatch[1]),
    status,
    temperature: Number(tempMatch[1]) / 10,
  };
}

/** 单个设备的电池行 */
function DeviceRow({ device }: { device: AdbDeviceHandle }): React.JSX.Element {
  const shellService = useService(ShellService);
  const [battery, setBattery] = useState<BatteryInfo | null>(null);

  useEffect(() => {
    let cancelled = false;

    const fetchBattery = async (): Promise<void> => {
      try {
        const output = await shellService.exec(device, 'dumpsys battery');
        if (!cancelled) setBattery(parseBatteryDump(output));
      } catch {
        if (!cancelled) setBattery(null);
      }
    };

    void fetchBattery();
    const interval = setInterval(() => void fetchBattery(), 5000);
    return () => { cancelled = true; clearInterval(interval); };
  }, [device, shellService]);

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
      <Text ellipsis style={{ flex: 1 }}>{device.sn}</Text>
      {battery ? (
        <>
          <Text strong style={{ color: battery.level > 20 ? '#52c41a' : '#ff4d4f' }}>
            {battery.level}%
          </Text>
          {battery.status === 'charging' && (
            <ThunderboltOutlined style={{ color: '#faad14' }} />
          )}
          <Text type="secondary">{battery.temperature}°C</Text>
        </>
      ) : (
        <Text type="secondary">--</Text>
      )}
    </div>
  );
}

/** Card 主组件 */
const BatteryCardView: React.FC = () => {
  const devicesService = useService(DevicesService);
  const [devices] = useServiceState(devicesService, 'devices');

  if (devices.length === 0) {
    return (
      <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: 16 }}>
        未连接设备
      </Text>
    );
  }

  return (
    <List
      size="small"
      dataSource={devices as AdbDeviceHandle[]}
      renderItem={(device) => (
        <List.Item key={device.sn} style={{ padding: '6px 0' }}>
          <DeviceRow device={device} />
        </List.Item>
      )}
    />
  );
};

export const BatteryCard: AutumnCard = {
  id: 'battery',
  name: 'card.name.battery',
  component: BatteryCardView,
};

组件名约定

Card 组件名建议使用 XxxCardView 后缀(如 BatteryCardView),不要以 Card 结尾。导出常量才以 Card 结尾(如 BatteryCard),否则构建系统的正则匹配会产生误判。

# 自定义 DOM 模式

如果你不使用 React(例如需要集成 Canvas、WebGL、或第三方 DOM 库),可以使用 mount 模式:

// src/cards/CanvasCard.tsx
import type { AutumnCard } from '@autumnbox/sdk';

export const CanvasCard: AutumnCard = {
  id: 'canvas-monitor',
  name: 'card.name.canvas_monitor',
  mount(container: HTMLElement): () => void {
    const canvas = document.createElement('canvas');
    canvas.width = 200;
    canvas.height = 100;
    canvas.style.width = '100%';
    container.appendChild(canvas);

    const ctx = canvas.getContext('2d')!;
    let animationId: number;
    let offset = 0;

    function draw(): void {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.strokeStyle = '#1677ff';
      ctx.lineWidth = 2;
      ctx.beginPath();
      for (let x = 0; x < canvas.width; x++) {
        const y = 50 + Math.sin((x + offset) * 0.05) * 30;
        x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      }
      ctx.stroke();
      offset += 2;
      animationId = requestAnimationFrame(draw);
    }

    draw();

    // 返回 dispose 函数:清理动画和 DOM
    return () => {
      cancelAnimationFrame(animationId);
      container.removeChild(canvas);
    };
  },
};

mount 模式的注意事项

  • mount 函数必须返回一个 dispose 函数,用于清理所有副作用(移除 DOM 节点、取消定时器、断开事件监听等)
  • 宿主会在插件卸载或页面关闭时调用 dispose 函数
  • mount 模式下无法使用 React hooks(如 useService、useServiceState),需要通过其他方式获取服务

# 在 Card 中使用服务

Card 组件不接收 props,所有数据和功能通过 hooks 从 IoC 容器获取。

# useService — 获取服务实例

import { useService } from '@autumnbox/sdk/app';
import { DevicesService, ShellService } from '@autumnbox/sdk/core';

const MyCardView: React.FC = () => {
  const devicesService = useService(DevicesService);
  const shellService = useService(ShellService);
  // ...
};

useService 从 IoC 容器中解析服务单例。服务实例在整个应用生命周期内保持不变。

# useServiceState — 订阅响应式状态

import { useService, useServiceState } from '@autumnbox/sdk/app';
import { DevicesService } from '@autumnbox/sdk/core';

const MyCardView: React.FC = () => {
  const devicesService = useService(DevicesService);

  // 方式一:服务实例 + 属性名
  const [devices] = useServiceState(devicesService, 'devices');

  // 方式二:直接传入 IReadonlyState 对象
  const [devices2] = useServiceState(devicesService.devices);

  // 方式三:服务类 + 属性名(自动解析实例)
  const [devices3] = useServiceState(DevicesService, 'devices');
};

useServiceState 订阅一个 IReadonlyState<V> 并在值变化时触发重渲染。三种调用方式效果一致,选择你偏好的风格。

# useT — 国际化翻译

import { useT } from '@autumnbox/sdk/app';

const MyCardView: React.FC = () => {
  const title = useT('card.title.my_card');
  const description = useT('card.description.my_card');

  return (
    <div>
      <h4>{title}</h4>
      <p>{description}</p>
    </div>
  );
};

useT 返回翻译后的字符串,当用户切换语言时自动更新。翻译 key 在 resources/lang/{locale}.json 中定义。

# 完整示例:订阅设备列表

// src/cards/DeviceStatusCard.tsx
import type { AutumnCard } from '@autumnbox/sdk';
import type { AdbDeviceHandle } from '@autumnbox/sdk/core';

import { MobileOutlined } from '@ant-design/icons';
import { useService, useServiceState, useT } from '@autumnbox/sdk/app';
import { DevicesService } from '@autumnbox/sdk/core';
import { Tag, Typography } from 'antd';

const { Text } = Typography;

const DeviceStatusCardView: React.FC = () => {
  const devicesService = useService(DevicesService);
  const [devices] = useServiceState(devicesService, 'devices');
  const title = useT('card.title.device_status');
  const noDevices = useT('card.no_devices');

  if (devices.length === 0) {
    return (
      <div style={{ textAlign: 'center', padding: '16px 0' }}>
        <MobileOutlined style={{ fontSize: 24, opacity: 0.2 }} />
        <Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
          {noDevices}
        </Text>
      </div>
    );
  }

  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
      {(devices as AdbDeviceHandle[]).map((d) => (
        <Tag key={d.sn} color={d.state === 'device' ? 'green' : 'red'}>
          {d.sn}
        </Tag>
      ))}
    </div>
  );
};

export const DeviceStatusCard: AutumnCard = {
  id: 'device-status',
  name: 'card.name.device_status',
  component: DeviceStatusCardView,
};

# 响应式状态原语

Card 的数据驱动依赖 AutumnBox 的响应式状态系统。理解这套机制有助于正确使用 useServiceState。

# IReadonlyState 接口

/**
 * 只读响应式状态。消费者只能读取和订阅。
 */
interface IReadonlyState<V> {
  /** 当前值 */
  readonly value: V;

  /**
   * 订阅值变化。
   * @param listener 值变化时的回调
   * @returns 取消订阅函数
   */
  subscribe(listener: (value: V) => void): () => void;
}

# IState 接口(可写)

/**
 * 可变响应式状态,继承 IReadonlyState。
 * 服务内部使用,通常不暴露给外部。
 */
interface IState<V> extends IReadonlyState<V> {
  value: V; // 可读可写
}

# 创建状态

import { createReadonlyState } from '@autumnbox/sdk/common';

// 创建只读状态 + 私有 setter(类似 React 的 useState)
const [state, setState] = createReadonlyState<number>(0);

state.value;              // 0
state.subscribe((v) => console.log('changed:', v));
setState(42);             // 触发所有订阅者

# useServiceState 内部机制

useServiceState 的核心实现原理非常简洁:

function useServiceState<V>(state: IReadonlyState<V>): [V] {
  const [value, setValue] = useState(state.value);
  useEffect(() => state.subscribe(setValue), [state]);
  return [value];
}

它做了三件事:

  1. 用 useState 保存当前值
  2. 用 useEffect 订阅状态变化,变化时调用 setValue 触发 React 重渲染
  3. 组件卸载时,subscribe 返回的 unsubscribe 函数自动清理订阅

如果状态是可写的 IState<V>,useServiceState 会返回 [value, setter] 元组,类似 React 的 useState。

# 命名约定

规则 说明 示例
文件位置 src/cards/ 目录 src/cards/BatteryCard.tsx
目录方式 支持子目录 + index.tsx src/cards/BatteryCard/index.tsx
导出名 必须以 Card 结尾 export const BatteryCard
组件名 不要以 Card 结尾 BatteryCardView、BatteryPanel
id 推荐 kebab-case "battery"、"device-status"
name 推荐 i18n key 格式 "card.name.battery"

构建系统使用以下正则匹配导出:

export\s+const\s+(\w+Card)\b

这意味着:

  • export const BatteryCard — 会被发现
  • export const batteryCard — 不会被发现(首字母未大写不匹配 \w+Card... 实际上 \w 包含小写字母,也会匹配。但按照约定应使用 PascalCase)
  • const BatteryCard = ... 然后 export { BatteryCard } — 不会被发现(必须是 export const 直接导出)
  • export default BatteryCard — 不会被发现(必须是具名导出)

常见错误

如果你的 React 组件名以 Card 结尾(如 export const BatteryCard: React.FC = ...),构建系统会尝试将其作为 AutumnCard 注册,导致运行时错误。请确保只有 AutumnCard 类型的对象以 Card 结尾。

# 自动发现机制

# 构建流程

当运行 autumnbox-sdk build 时:

  1. 扫描 — 构建系统递归扫描 src/cards/ 目录下的所有 .ts / .tsx 文件
  2. 匹配 — 对每个文件,用正则 export\s+const\s+(\w+Card)\b 查找所有匹配的具名导出
  3. 生成 — 在 src/__entry__.ts 中生成 import 语句和注册代码
  4. 构建 — Vite 以 __entry__.ts 为入口,构建 UMD bundle
  5. 打包 — 生成 .atmb 文件

# 生成的入口代码

假设插件有两个 Card 和一个 App,生成的 src/__entry__.ts 类似:

// 此文件由 autumnbox-sdk build 自动生成,请勿手动修改。
import type { PluginContext } from '@autumnbox/sdk';

import { ShellApp } from './apps/ShellApp';
import { BatteryCard } from './cards/BatteryCard';
import { RebootCard } from './cards/RebootCard';

export function __autumnbox_entry__(context: PluginContext): () => void {
  const disposers: Array<() => void> = [];

  // Apps
  disposers.push(context.registerApp(ShellApp));

  // Cards
  disposers.push(context.registerCard(BatteryCard, RebootCard));

  return () => {
    for (const dispose of disposers) dispose();
  };
}

context.registerCard() 是 PluginContext 提供的便捷方法,它在内部调用 CardService.register() 并自动填充 pluginPackageName(拥有者插件的包名)。返回的 dispose 函数在插件卸载时取消注册。

# dist/package.json 元数据

构建完成后,dist/package.json 中会包含发现的 Card 信息:

{
  "name": "my-plugin",
  "version": "1.0.0",
  "autumnbox": {
    "cards": [
      { "export": "BatteryCard" },
      { "export": "RebootCard" }
    ]
  }
}

# Card 与 App 对比

特性 App Card
显示位置 标签页(Tab) 首页侧边栏
打开方式 用户从应用列表点击打开 始终显示,无需操作
实例数 可以多实例(非 singleton 时) 始终单实例
设备绑定 支持 shallSelectAdbDevice 不支持,自行获取设备列表
图标 必须提供 icon 无 icon 属性
分类标签 支持 tags 无 tags
组件 Props React.FC<AppProps> React.FC(无 props)
数据获取 Props + hooks 仅 hooks
文件位置 src/apps/ src/cards/
导出名后缀 App Card
典型用途 完整功能界面、交互式工具 状态摘要、快捷操作、实时监控
自定义 DOM 支持 mount 支持 mount

# 完整实战示例

# 示例一:设备连接状态卡片

展示所有已连接设备的 CPU 和内存使用率,点击可跳转到设备详情:

// src/cards/DeviceStatusCard.tsx
import type { AutumnCard } from '@autumnbox/sdk';
import type { AdbDeviceHandle } from '@autumnbox/sdk/core';

import { MobileOutlined } from '@ant-design/icons';
import { useNavigation, useService, useServiceState, useT } from '@autumnbox/sdk/app';
import { DevicesService, ShellService } from '@autumnbox/sdk/core';
import { List, Progress, Typography } from 'antd';
import { useEffect, useState } from 'react';

const { Text } = Typography;

function parseCpuUsage(output: string): number {
  const match = /([\d.]+)%\s*TOTAL/i.exec(output);
  return match ? Math.round(Number(match[1])) : 0;
}

function parseMemUsage(output: string): number {
  const totalMatch = /MemTotal:\s*(\d+)/i.exec(output);
  const availMatch = /MemAvailable:\s*(\d+)/i.exec(output);
  if (!totalMatch || !availMatch) return 0;
  const total = Number(totalMatch[1]);
  const available = Number(availMatch[1]);
  return total === 0 ? 0 : Math.round(((total - available) / total) * 100);
}

function DeviceRow({ device }: { device: AdbDeviceHandle }): React.JSX.Element {
  const shellService = useService(ShellService);
  const navigation = useNavigation();
  const [cpuPercent, setCpuPercent] = useState<number | null>(null);
  const [memPercent, setMemPercent] = useState<number | null>(null);

  // 每 3 秒轮询 CPU 和内存
  useEffect(() => {
    let cancelled = false;

    const poll = async (): Promise<void> => {
      try {
        const [cpuOut, memOut] = await Promise.all([
          shellService.exec(device, 'dumpsys cpuinfo | head -1'),
          shellService.exec(device, 'cat /proc/meminfo'),
        ]);
        if (cancelled) return;
        setCpuPercent(parseCpuUsage(cpuOut));
        setMemPercent(parseMemUsage(memOut));
      } catch {
        if (cancelled) return;
        setCpuPercent(null);
        setMemPercent(null);
      }
    };

    void poll();
    const interval = setInterval(() => void poll(), 3000);
    return () => { cancelled = true; clearInterval(interval); };
  }, [device, shellService]);

  const handleClick = (): void => {
    navigation.open(`autumnbox://device-detail?sn=${encodeURIComponent(device.sn)}`);
  };

  return (
    <div onClick={handleClick} style={{ cursor: 'pointer', padding: '4px 0' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
        <MobileOutlined style={{ fontSize: 14 }} />
        <Text ellipsis style={{ fontSize: 13, fontWeight: 500 }}>{device.sn}</Text>
      </div>
      <div style={{ paddingLeft: 20 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          <Text type="secondary" style={{ fontSize: 11, width: 28 }}>CPU</Text>
          {cpuPercent != null
            ? <Progress percent={cpuPercent} size="small" style={{ flex: 1 }} />
            : <Text type="secondary">--</Text>}
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          <Text type="secondary" style={{ fontSize: 11, width: 28 }}>MEM</Text>
          {memPercent != null
            ? <Progress percent={memPercent} size="small" strokeColor="#722ed1" style={{ flex: 1 }} />
            : <Text type="secondary">--</Text>}
        </div>
      </div>
    </div>
  );
}

const DeviceStatusCardView: React.FC = () => {
  const [devices] = useServiceState(DevicesService, 'devices');
  const noDevices = useT('device_status.no_devices');

  if (devices.length === 0) {
    return (
      <div style={{ textAlign: 'center', padding: '16px 0' }}>
        <MobileOutlined style={{ fontSize: 24, opacity: 0.2 }} />
        <Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{noDevices}</Text>
      </div>
    );
  }

  return (
    <List
      size="small"
      dataSource={devices as AdbDeviceHandle[]}
      renderItem={(device) => (
        <List.Item key={device.sn} style={{ padding: '4px 0' }}>
          <DeviceRow device={device} />
        </List.Item>
      )}
    />
  );
};

export const DeviceStatusCard: AutumnCard = {
  id: 'device-status',
  name: 'card.name.device_status',
  component: DeviceStatusCardView,
};

# 示例二:快速重启卡片

提供多种重启模式的快捷按钮:

// src/cards/RebootCard.tsx
import type { AutumnCard } from '@autumnbox/sdk';
import type { AdbDeviceHandle } from '@autumnbox/sdk/core';

import { PoweroffOutlined, ToolOutlined } from '@ant-design/icons';
import { useService, useServiceState, useT } from '@autumnbox/sdk/app';
import { DevicesService, ShellService } from '@autumnbox/sdk/core';
import { Button, message } from 'antd';

interface RebootMode {
  key: string;
  labelKey: string;
  command: string;
  icon: React.ReactNode;
}

const REBOOT_MODES: RebootMode[] = [
  { key: 'system',    labelKey: 'reboot.system',    command: 'reboot',              icon: <PoweroffOutlined /> },
  { key: 'recovery',  labelKey: 'reboot.recovery',  command: 'reboot recovery',     icon: <ToolOutlined /> },
  { key: 'bootloader',labelKey: 'reboot.bootloader', command: 'reboot bootloader',  icon: <ToolOutlined /> },
  { key: 'fastboot',  labelKey: 'reboot.fastboot',  command: 'reboot fastboot',     icon: <ToolOutlined /> },
];

const RebootCardView: React.FC = () => {
  const shellService = useService(ShellService);
  const [devices] = useServiceState(DevicesService, 'devices');

  const handleReboot = async (mode: RebootMode): Promise<void> => {
    const deviceList = devices as AdbDeviceHandle[];
    if (deviceList.length === 0) {
      void message.warning('请先连接设备');
      return;
    }
    try {
      // 对所有已连接设备执行重启
      await Promise.all(deviceList.map((d) => shellService.exec(d, mode.command)));
      void message.success(`已发送 ${mode.key} 重启命令`);
    } catch {
      void message.error('重启失败');
    }
  };

  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8 }}>
      {REBOOT_MODES.map((mode) => (
        <RebootButton key={mode.key} mode={mode} onClick={() => void handleReboot(mode)} />
      ))}
    </div>
  );
};

function RebootButton({
  mode,
  onClick,
}: {
  mode: RebootMode;
  onClick: () => void;
}): React.JSX.Element {
  const label = useT(mode.labelKey);
  return (
    <Button
      size="small"
      icon={mode.icon}
      onClick={onClick}
      style={{ height: 40, fontSize: 13 }}
    >
      {label}
    </Button>
  );
}

export const RebootCard: AutumnCard = {
  id: 'reboot',
  name: 'card.name.reboot',
  component: RebootCardView,
};

# 示例三:自定义 DOM 模式 — 实时时钟

// src/cards/ClockCard.tsx
import type { AutumnCard } from '@autumnbox/sdk';

export const ClockCard: AutumnCard = {
  id: 'clock',
  name: 'card.name.clock',
  mount(container: HTMLElement): () => void {
    const el = document.createElement('div');
    el.style.cssText = 'text-align:center; font-size:24px; font-weight:600; padding:12px 0;';
    container.appendChild(el);

    function tick(): void {
      el.textContent = new Date().toLocaleTimeString();
    }

    tick();
    const timer = setInterval(tick, 1000);

    return () => {
      clearInterval(timer);
      container.removeChild(el);
    };
  },
};

# 错误处理

宿主应用会自动为每个 Card 包裹 <ErrorBoundary>。如果 Card 组件在渲染过程中抛出异常,不会导致整个应用崩溃,而是在该 Card 位置显示错误提示:

Card "card.name.battery" failed to render: Cannot read properties of undefined

但这不意味着你可以忽略错误处理。最佳实践:

  • 对异步操作使用 try/catch
  • 对可能为 null 的数据提供加载态和空态
  • 使用 cancelled 标志防止组件卸载后的状态更新

# 国际化

Card 的 name 属性以及组件中使用的所有文本都应通过 i18n 系统管理。在 resources/lang/ 下放置翻译文件:

// resources/lang/zh-CN.json
{
  "card.name.battery": "电池状态",
  "card.name.device_status": "设备状态",
  "card.name.reboot": "快速重启",
  "battery.no_devices": "未连接设备",
  "battery.charging": "充电中",
  "battery.discharging": "放电中"
}
// resources/lang/en.json
{
  "card.name.battery": "Battery",
  "card.name.device_status": "Device Status",
  "card.name.reboot": "Quick Reboot",
  "battery.no_devices": "No devices connected",
  "battery.charging": "Charging",
  "battery.discharging": "Discharging"
}

构建时 SDK 会校验 i18n 完整性:如果某个 Card 的导出名在任何语言文件中都没有对应 key,会输出警告。

# 常见问题

# Card 没有出现在首页

  1. 检查文件是否在 src/cards/ 目录下
  2. 检查导出是否使用 export const + Card 后缀
  3. 运行构建,查看控制台输出 Discovered ... card(s) 确认被发现
  4. 检查 src/__entry__.ts 是否包含该 Card 的 import 和注册代码

# useServiceState 没有触发更新

确保订阅的是 IReadonlyState 对象,而非普通属性。服务中需要使用 createReadonlyState 创建响应式状态:

// 正确 — DevicesService.devices 是 IReadonlyState<...>
const [devices] = useServiceState(devicesService, 'devices');

// 错误 — 普通属性不是响应式的
const value = devicesService.someNonReactiveProperty;

# 一个文件导出多个 Card

完全支持。构建系统的正则会匹配文件中所有的 export const XxxCard:

export const BatteryCard: AutumnCard = { id: 'battery', /* ... */ };
export const TemperatureCard: AutumnCard = { id: 'temperature', /* ... */ };
最后更新: 4/8/2026, 2:35:44 AM