# 组件开发指南

Page（页面）和 Widget（卡片）组件的完整开发指南。

---

## 🎯 Page 开发指南

### App 配置、可选入口与多级目录

`src/app.config.ts` 是应用配置文件，需要配置 `appId` 和 `name`，`appId` 使用开放平台 `db_xxxxxx` 风格。`pages` / `widgets` 是可选的显式入口配置。
一级页面目录会自动发现，例如 `src/pages/home/index.tsx` 会产出页面路径 `/pages/home/index`；不配置到 `pages` 时仍可构建，
但 `title`、`description` 等页面 metadata 为空。

需要补 metadata、固定首页顺序或声明多级目录时，再配置 `pages` 数组。数组第一项会作为应用首页
（最终 manifest 的 `appletEntry`）。显式声明入口时，entry 写到 `index`，例如 `'pages/home/index'`，
不要省略入口文件名。

如果不配置 `pages` / `widgets`，一级 Page / Widget 仍会按目录产出；Page 的跳转路径按入口推导，Widget 的默认 ID 取入口最后一级目录名，
页面/卡片 metadata 字段为空，Widget 的 `boxType` 默认为 `inbox`。没有显式 `pages` 顺序时，默认首页入口为
`index`；首页不是 `index` 时建议配置 `pages` 第一项。

多级目录不会被一级扫描自动发现，例如 `src/pages/account/demo/index.tsx` 需要显式写
`entry: 'pages/account/demo/index'`。页面跳转使用 entry 对应的路径，例如 `/pages/account/demo/index`。

```ts
import { defineAppConfig } from '@doubao-dev/framework/config';

export default defineAppConfig({
  appId: 'db_xxxxxx',
  name: '我的豆包应用',
  pages: [
    'pages/home/index',
    'pages/account/demo/index',
    {
      entry: 'pages/profile/index',
      title: '资料页',
      description: '补充 metadata 的页面示例'
    }
  ]
});
```

### 基本结构

```tsx
import { useDestroy, useHide, useShow, useViewData } from '@doubao-dev/framework';
import './index.scss';

interface PageViewData {
  title: string;
}

export default function Page() {
  useShow(() => console.log('页面显示'));
  useHide(() => console.log('页面隐藏'));
  useDestroy(() => console.log('页面销毁'));

  const viewData = useViewData<PageViewData>();
  return <MyPageComponent {...viewData} />;
}
```

### 页面布局示例

**1. 默认页面布局**
普通 Page 默认采用可滚动安全区布局：根节点使用垂直 `scroll-view`，内容区预留顶部安全距离，避免状态栏和右上角胶囊按钮遮挡。
完整规则和样式写法见 [开发规则](../rules/dos-and-donts.md)。
```tsx
export default function Page() {
  return (
    <scroll-view className="full-page" scroll-orientation="vertical" enable-scroll={true}>
      <view className="full-page__content">
        <view className="full-page__header">头部</view>
        <view className="full-page__body">内容区域</view>
        <view className="full-page__footer">底部</view>
      </view>
    </scroll-view>
  );
}
```

**2. 九宫格布局示例**

```tsx
export default function GridPage() {
  const items = [
    { id: '1', title: '应用1', icon: '🎯' },
    { id: '2', title: '应用2', icon: '📱' },
    { id: '3', title: '应用3', icon: '⚡' }
  ];

  return (
    <view className="grid-container">
      {items.map(item => (
        <view key={item.id} className="grid-item">
          <text className="icon">{item.icon}</text>
          <text className="title">{item.title}</text>
        </view>
      ))}
    </view>
  );
}
```

### 页面参数接收

```tsx
import { useViewData } from '@doubao-dev/framework';

interface PageViewData {
  userId: string;
  tab?: string;
}

export default function ProfilePage() {
  const viewData = useViewData<PageViewData>();
  const { userId, tab = 'profile' } = viewData;

  return (
    <view>
      <text>用户 ID: {userId}</text>
      <text>当前标签: {tab}</text>
    </view>
  );
}
```

### 页面导航

```tsx
import { navigateTo, redirectTo, reLaunch } from '@doubao-dev/framework/api';

// 保留当前页面，打开新页面
navigateTo({
  url: '/pages/user-detail/index?userId=123&tab=profile'
});

// 替换当前页面
redirectTo({
  url: '/pages/settings/index'
});

// 关闭所有页面并回到首页
reLaunch({
  url: '/pages/home/index'
});
```

`url` 在常规页面跳转里填写目标 Page 路径，通常和 `src/pages/<page-name>/index.tsx` 对应，例如
`/pages/detail/index`。

页面内通过 `useViewData<PageViewData>()` 读取 URL 中传入的参数。

### 常用模式

**1. 数据加载**

```tsx
import { useEffect, useState } from '@doubao-dev/framework';
import { request } from '@doubao-dev/framework/api';

interface UserData {
  name?: string;
}

function MyPage({ userId }: { userId: string }) {
  const [data, setData] = useState<UserData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError('');
        const result = await request({
          url: `https://api.example.com/users/${userId}`,
          method: 'GET'
        });
        setData(result.data as UserData);
      } catch (err) {
        setError(err instanceof Error ? err.message : '加载失败');
      } finally {
        setLoading(false);
      }
    };

    void fetchData();
  }, [userId]);

  if (loading) return <text>加载中...</text>;
  if (error) return <text>错误: {error}</text>;
  if (!data) return <text>无数据</text>;

  return <view>{/* 渲染数据 */}</view>;
}
```

**2. 表单提交**

```tsx
import { useState } from '@doubao-dev/framework';
import { navigateBack, request, showToast } from '@doubao-dev/framework/api';

function FormPage() {
  const [formData, setFormData] = useState({
    name: '',
    email: ''
  });
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async () => {
    try {
      setSubmitting(true);
      await request({
        url: 'https://api.example.com/submit',
        method: 'POST',
        data: formData
      });
      showToast({ message: '提交成功' });
      navigateBack(); // 返回上一页
    } catch (err) {
      showToast({ message: '提交失败' });
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <view>
      <input
        value={formData.name}
        onInput={e => setFormData({ ...formData, name: e.detail.value })}
        placeholder="姓名"
      />
      <input
        value={formData.email}
        onInput={e => setFormData({ ...formData, email: e.detail.value })}
        placeholder="邮箱"
      />
      <button onClick={handleSubmit} disabled={submitting}>
        {submitting ? '提交中...' : '提交'}
      </button>
    </view>
  );
}
```

---

## 🎴 Widget 开发指南

### 可选 Widgets 配置

一级 Widget 目录会自动发现，例如 `src/widgets/my-widget/index.tsx` 会产出 `widgetId: 'my-widget'`。不配置
`widgets` 时仍可构建，但 `name`、`description` 等 metadata 为空，`boxType` 默认是 `inbox`。
`titleType` 需要显式传值；默认无标题写 `titleType: 'none'`。

Widget 的卡片内容和布局必须使用 [Widget 模板库](../widget-templates/overview.md) 中的 `@doubao-dev/template` 模板完成。创建
Widget 时先读模板库的选型指南和 props 参考，选中模板后再回到本指南处理入口组件、`useViewData<T>()`、生命周期、
`boxType`、`titleType` 和端能力调用。

不要在 Widget 中手写 `view` / `text` / `image` 拼卡片布局。现有 Props 无法覆盖的内容，按模板能力或新设计需求确认。

需要补 metadata 或声明多级目录时，再配置 `widgets` 数组。显式声明入口时，entry 写到 `index`，例如
`'widgets/my-widget/index'`，不要省略入口文件名。
多级目录如 `src/widgets/order/detail/index.tsx` 需要显式写 `'widgets/order/detail/index'`。
如果不配置 `id`，默认 `widgetId` 是最后一级目录名 `detail`。Widget ID 必须在应用内全局唯一；如果多个入口得到
相同 ID，构建会失败，需要在 `app.config.ts` 中为它们配置不同的 `id`。

```ts
import { defineAppConfig } from '@doubao-dev/framework/config';

export default defineAppConfig({
  appId: 'db_xxxxxx',
  name: '我的豆包应用',
  widgets: [
    {
      entry: 'widgets/simple-card/index',
      titleType: 'none'
    },
    {
      entry: 'widgets/order/detail/index',
      titleType: 'none'
    },
    {
      entry: 'widgets/my-widget/index',
      id: 'my-widget',                 // 卡片唯一标识
      name: '我的卡片',                 // 卡片名称
      description: '卡片功能描述',       // 卡片描述
      boxType: 'inbox',                // 卡片类型: inbox | full_box
      border: true,
      keywords: ['demo', 'widget'],
      titleType: 'none'                // 不展示标题栏
    }
  ]
});
```

### 基本结构

```tsx
import {
  useBackground,
  useDestroy,
  useForeground,
  useHide,
  useMounted,
  useShow,
  useViewData
} from '@doubao-dev/framework';
import { ContentCard, type ContentCardItem } from '@doubao-dev/template';

interface MyWidgetData {
  items: ContentCardItem[];
}

export default function Widget() {
  useShow(() => console.log('卡片显示'));
  useHide(() => console.log('卡片隐藏'));
  useForeground(() => console.log('应用回到前台'));
  useBackground(() => console.log('应用进入后台'));
  useMounted(() => console.log('卡片挂载'));
  useDestroy(() => console.log('卡片销毁'));

  const viewData = useViewData<MyWidgetData>();
  return <ContentCard items={viewData.items} />;
}
```

### 卡片类型 (boxType)

**1. inbox - 普通卡片**

默认卡片类型，适用于绝大多数场景（信息展示、结果回显、轻量交互）。

推荐使用场景：
- 内容相对简短：标题/摘要/列表/状态提示等
- 不需要占用整行宽度，希望保持对话流的紧凑阅读体验
- 交互以按钮、简单表单项为主

在 `src/app.config.ts` 的对应 Widget entry 上配置 `boxType: 'inbox'`。入口和 metadata 写法以
  [开发规则](../rules/dos-and-donts.md) 为准。卡片内容仍从 [Widget 模板库](../widget-templates/overview.md) 选模板，不因 `boxType`
  变化而手写布局。

```ts
// src/app.config.ts
import { defineAppConfig } from '@doubao-dev/framework/config';

export default defineAppConfig({
  appId: 'db_xxxxxx',
  name: '我的豆包应用',
  widgets: [
    {
      entry: 'widgets/info-card/index',
      id: 'info-card',
      name: '信息卡片',
      boxType: 'inbox',
      titleType: 'none'
    }
  ]
});
```

**2. full_box - 全宽卡片**

适用于需要更大展示空间的卡片（例如图表、图片墙、复杂表单等）。

在 `src/app.config.ts` 的对应 Widget entry 上配置 `boxType: 'full_box'`。入口和 metadata 写法以
[开发规则](../rules/dos-and-donts.md) 为准。需要全宽展示时，优先选择模板库中的列表、订单、票务或媒体布局模板；
匹配不上时按模板能力或新设计需求确认。

```ts
// src/app.config.ts
import { defineAppConfig } from '@doubao-dev/framework/config';

export default defineAppConfig({
  appId: 'db_xxxxxx',
  name: '我的豆包应用',
  widgets: [
    {
      entry: 'widgets/user-action-card/index',
      id: 'user-action-card',
      name: '全宽卡片示例',
      boxType: 'full_box',
      titleType: 'none'
    }
  ]
});
```

### ViewData 类型

Widget viewData 通过 `useViewData<T>()` 的泛型参数和 TypeScript 接口表达。数据结构应尽量贴近所选模板的 props
或列表项类型。

```tsx
import { useViewData } from '@doubao-dev/framework';
import { ContentCard, type ContentCardItem } from '@doubao-dev/template';

interface ProductCardData {
  title: string;
  imageSrc?: string;
  price?: string;
  quantity?: string;
  infoRows?: ContentCardItem[];
}

export default function ProductCard() {
  const viewData = useViewData<ProductCardData>();

  return (
    <ContentCard
      items={[
        {
          title: viewData.title,
          subtitle: [viewData.price, viewData.quantity].filter(Boolean).join(' | '),
          thumbnailSrc: viewData.imageSrc
        },
        ...(viewData.infoRows ?? [])
      ]}
    />
  );
}
```

### 常用模式

**1. 数据获取和展示**

```tsx
import { useEffect, useState, useViewData } from '@doubao-dev/framework';
import { request } from '@doubao-dev/framework/api';
import { ContentCard, type ContentCardItem } from '@doubao-dev/template';

interface WeatherInfo {
  city: string;
  temperature: number;
  condition: string;
  windSpeed: number;
}

export default function WeatherWidget() {
  const { city } = useViewData<{ city: string }>();
  const [weather, setWeather] = useState<WeatherInfo | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState('');

  useEffect(() => {
    const fetchWeather = async () => {
      try {
        setError('');
        const response = await request({
          url: `https://api.example.com/weather?city=${city}`,
          method: 'GET'
        });
        setWeather(response.data as WeatherInfo);
      } catch (err) {
        setError(err instanceof Error ? err.message : '获取天气失败');
      } finally {
        setLoading(false);
      }
    };

    void fetchWeather();
  }, [city]);

  if (loading) return <ContentCard items={[{ title: '加载中...' }]} />;
  if (error) return <ContentCard items={[{ title: error }]} />;
  if (!weather) return <ContentCard items={[{ title: '暂无数据' }]} />;

  const items: ContentCardItem[] = [
    { key: 'condition', title: weather.city, subtitle: weather.condition },
    { key: 'wind', title: '风速', subtitle: weather.windSpeed },
    { key: 'temperature', title: '温度', subtitle: `${weather.temperature}℃` }
  ];

  return <ContentCard items={items} />;
}
```

**2. 用户交互**

```tsx
import { useState } from '@doubao-dev/framework';
import { sendFollowUpMessage } from '@doubao-dev/framework/api';
import { AskHumanCard } from '@doubao-dev/template';

function InteractiveWidget({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);

  const handleIncrement = () => {
    const nextCount = count + 1;
    setCount(nextCount);
    // 以用户身份发送后续消息，触发新一轮对话
    void sendFollowUpMessage({
      content: [{ type: 'text', text: `用户将计数增加到 ${nextCount}，请基于新的计数继续回复` }]
    });
  };

  return (
    <AskHumanCard
      variant="jump"
      items={[
        {
          key: 'increment',
          text: `增加到 ${count + 1}`,
          showArrow: 'right',
          onClick: handleIncrement
        }
      ]}
    />
  );
}
```

**3. 打开页面**

```tsx
import { navigateTo } from '@doubao-dev/framework/api';
import { AskHumanCard } from '@doubao-dev/template';

function ActionWidget({ itemId }: { itemId: string }) {
  const handleViewDetail = () => {
    navigateTo({
      url: `/pages/item-detail/index?id=${itemId}`
    });
  };

  return (
    <AskHumanCard
      variant="jump"
      items={[
        {
          key: 'view-detail',
          text: '查看详情',
          showArrow: 'right',
          onClick: handleViewDetail
        }
      ]}
    />
  );
}
```

---

## 🎨 样式开发

### SCSS 文件组织

```scss
// 样式变量
$primary-color: #1890ff;
$text-color: #333;
$bg-color: #f5f5f5;
$border-radius: 8px;
$spacing: 16px;

// 容器样式
.container {
  padding: $spacing;
  background-color: $bg-color;

  .header {
    font-size: 36px;
    color: $text-color;
    margin-bottom: $spacing;
  }

  .content {
    background-color: #fff;
    border-radius: $border-radius;
    padding: $spacing;
  }
}

// 响应式布局
.responsive-layout {
  display: flex;
  flex-wrap: wrap;
  gap: $spacing;

  .item {
    flex: 1 1 calc(50% - #{$spacing});
    min-width: 200px;
  }
}
```

### 常用布局模式

本节布局示例面向 Page 的普通内容区。Widget 卡片整体布局不要手写，先使用
[Widget 模板库](../widget-templates/overview.md) 中的模板。

承载 `button`、`input`、`switch` 等内置组件时，还要按 [开发规则](../rules/dos-and-donts.md) 检查组件最小宽度、换行和收缩。

**1. Flex 布局**

```scss
.flex-container {
  display: flex;
  flex-direction: row;      // row | column
  justify-content: center;  // flex-start | center | flex-end | space-between
  align-items: center;      // flex-start | center | flex-end | stretch
  gap: 16px;
}
```

**2. Grid 布局**

```scss
.grid-container {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  padding: 16px;
}
```

**3. Page 内容块布局**

```scss
.content-block {
  background-color: #fff;
  border-radius: 8px;
  padding: 24px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  margin-bottom: 16px;
}
```

---

## 🔌 View Data APIs

在 Page 和 Widget 开发中，通过 `useViewData<T>()` 获取当前视图数据。

获取当前视图的数据，支持泛型类型定义，确保类型安全。

**类型定义**：
```typescript
import { useViewData } from '@doubao-dev/framework';

function useViewData<T extends Record<string, any> = Record<string, any>>(): T
```

**使用场景**：

1. **在入口组件和生命周期 Hook 中获取数据**：
```tsx
import { useMounted, useViewData } from '@doubao-dev/framework';
import { AskHumanCard } from '@doubao-dev/template';

interface TodoData {
  tasks: string[];
}

export default function TodoWidget() {
  const viewData = useViewData<TodoData>();
  useMounted(() => console.log('任务列表:', viewData.tasks));

  return (
    <AskHumanCard
      variant="jump"
      items={[
        {
          key: 'task-count',
          text: `${viewData.tasks.length} 个任务`,
          showArrow: 'right'
        }
      ]}
    />
  );
}
```

2. **在自定义函数中访问数据**：
```tsx
import { useViewData } from '@doubao-dev/framework';
import { ContentCard } from '@doubao-dev/template';

interface WeatherData {
  city: string;
  temperature: number;
}

function WeatherWidget() {
  const viewData = useViewData<WeatherData>();

  const loadWeatherIcon = () => {
    return viewData.temperature > 25 ? '☀️' : '❄️';
  };

  return <ContentCard items={[{ title: loadWeatherIcon() }]} />;
}
```

**注意事项**：
- ✅ **推荐**：在入口组件函数中使用 `useViewData<T>()` 获取数据
- ✅ **推荐**：生命周期 Hook 可以闭包访问入口组件读取的数据
- 🔒 **类型安全**：始终定义明确的 TypeScript 接口并传递给泛型参数

## 🔧 开发最佳实践

### 1. TypeScript 类型定义

```tsx
// 定义 props 类型
interface ComponentProps {
  title: string;
  count?: number;
  onAction: (value: string) => void;
}

// 定义 state 类型
interface ComponentState {
  loading: boolean;
  data: DataType | null;
  error: string | null;
}

function MyComponent({ title, count = 0, onAction }: ComponentProps) {
  const [state, setState] = useState<ComponentState>({
    loading: false,
    data: null,
    error: null
  });

  return <view>{/* 组件内容 */}</view>;
}
```

### 2. 错误处理

```tsx
try {
  // 异步操作
  const result = await someAsyncOperation();
  setData(result);
} catch (err) {
  const message = err instanceof Error ? err.message : '未知错误';
  // 记录错误
  console.error('操作失败:', message);

  // 显示用户友好的错误消息
  setError('操作失败，请重试');

  // 可选：上报错误
  reportAppLog({
    level: 'error',
    message,
    context: { operation: 'someAsyncOperation' }
  });
}
```

### 3. 性能优化

```tsx
import { useMemo, useCallback } from '@doubao-dev/framework';

function OptimizedComponent({ data, onSelect }) {
  // 缓存计算结果
  const processedData = useMemo(() => {
    return data.map(item => ({
      ...item,
      computed: expensiveComputation(item)
    }));
  }, [data]);

  // 缓存回调函数
  const handleClick = useCallback((id: string) => {
    onSelect(id);
  }, [onSelect]);

  return (
    <view>
      {processedData.map(item => (
        <view key={item.id} onClick={() => handleClick(item.id)}>
          {item.computed}
        </view>
      ))}
    </view>
  );
}
```

### 4. 生命周期管理

```tsx
useEffect(() => {
  // 订阅
  const subscription = subscribeEvent('data-update', handleDataUpdate);

  // 定时器
  const timer = setInterval(() => {
    fetchData();
  }, 5000);

  // 清理函数
  return () => {
    unsubscribeEvent(subscription);
    clearInterval(timer);
  };
}, []);
```

---

## 📚 延伸阅读

- **Page/Widget 基础示例** → [../examples/page-widget-basics.md](../examples/page-widget-basics.md)
- **常用模式** → [../examples/common-patterns.md](../examples/common-patterns.md)
- **登录与授权约定入口** → [auth.md](./auth.md)
- **失效卡片** → [expired-widget.md](./expired-widget.md)
- **豆包智能服务的端能力 API 使用配方** → [../examples/doubao-agentic-service-api-recipes.md](../examples/doubao-agentic-service-api-recipes.md)
- **Page 组件配方** → [../examples/page-widget-recipes.md](../examples/page-widget-recipes.md)
- **最佳实践** → [best-practices.md](./best-practices.md)
- **性能优化** → [performance-optimization.md](./performance-optimization.md)
- **故障排查** → [troubleshooting.md](./troubleshooting.md)
- **Framework 核心入口、生命周期和 Hooks** → [../framework/core.md](../framework/core.md)
- **豆包智能服务的端能力 API 速查** → [../doubao-agentic-service-api/quick-reference.md](../doubao-agentic-service-api/quick-reference.md)
