# Predix Widget SDK

用于在 React、Vue 或原生 TypeScript 页面中嵌入 Predix 赛事网格、赛事轮播和赛事卡片。

## 目录

1. [快速开始](#1-快速开始)
  - [React + TypeScript](#11-react--typescript)
   - [原生 TypeScript / ESM](#12-原生-typescript--esm)
  - [原生 HTML + CDN](#121-原生-html--cdn无需安装)
  - [Vue 3 + TypeScript](#13-vue-3--typescript)
2. [获取 Widget Token](#2-获取-widget-token)
  - [Widget Token 与 Player 登录的区别](#21-widget-token-与-player-登录的区别)
  - [实现 tokenProvider](#22-实现-tokenprovider)
  - [游客与会员启动方式](#23-游客与会员启动方式)
3. [前端 API 文档](#3-前端-api-文档)
  - [初始化参数](#31-初始化参数)
  - [Provider 与生命周期](#32-provider-与生命周期)
  - [组件](#33-组件)
  - [事件](#34-事件)
  - [主题与本地化](#35-主题与本地化)
  - [React 与 Vue 包装 API](#36-react-与-vue-包装-api)
4. [其它](#4-其它)
  - [缓存与 Realtime](#41-缓存与-realtime)
  - [加载方式](#42-加载方式)
  - [安全与兼容性](#43-安全与兼容性)
  - [故障排查](#44-故障排查)
  - [延伸文档](#45-延伸文档)

## 1. 快速开始

三种方式使用相同的初始化参数和组件能力，并统一演示游客模式：Widget 使用 `tokenProvider` 获取赛事数据访问凭证；用户点击赛事后，Host 调用自己的 `/api/predix/visit-game` BFF 获取游客 Player 地址。生产环境可在 BFF 中根据登录状态切换为会员启动，详见[游客与会员启动方式](#23-游客与会员启动方式)。

开始前需要准备 Widget API 地址，并在当前 Host 域名下提供两个 BFF 路由：

- `POST /api/predix/widget-token`：返回 `{ token, expiresIn }`。
- `POST /api/predix/visit-game`：返回 `{ gameUrl }`。

以下示例默认由同源 BFF 保存 Agent API Key 和 Security Key；浏览器不得直接调用 Agent API。BFF 实现见[获取 Widget Token](#2-获取-widget-token)和[游客与会员启动方式](#23-游客与会员启动方式)。

示例中的 `HOST_API_BASE_URL` 默认留空，通过当前 Host 的同源反向代理访问 BFF。若必须跨 Origin 部署，需改用 `credentials: 'include'`，并在 BFF 正确配置 CORS 和跨站 Cookie；涉及会员 Session 时仍推荐保持同源。

### 1.1 React + TypeScript

需要 React 19 或更高版本。

```bash
npm install predix-widget react react-dom
```

```tsx
import { useMemo } from 'react'
import {
  PredixEventCarousel,
  PredixEventList,
  PredixProvider,
} from 'predix-widget/react'

// 留空表示使用当前 Host 的同源 BFF，推荐通过反向代理保持同源。
const HOST_API_BASE_URL = ''

async function requestWidgetToken() {
  const response = await fetch(`${HOST_API_BASE_URL}/api/predix/widget-token`, {
    method: 'POST',
    credentials: 'same-origin',
  })
  if (!response.ok) throw new Error(`Token request failed: ${response.status}`)
  const value: unknown = await response.json()
  const result = value as { token?: unknown; expiresIn?: unknown }
  if (
    typeof result.token !== 'string'
    || !result.token
    || !Number.isFinite(result.expiresIn)
    || Number(result.expiresIn) <= 0
  ) {
    throw new Error('Invalid Widget Token response')
  }
  return { token: result.token, expiresIn: Number(result.expiresIn) }
}

async function openAsVisitor({ eventId, outcomeId }: {
  eventId: string
  outcomeId?: string
}) {
  // 会员启动：将此处改为 /api/predix/launch-game；
  // account 和 currency 应由 BFF 从服务端 Session 读取，不要从浏览器传入。
  const response = await fetch(`${HOST_API_BASE_URL}/api/predix/visit-game`, {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ event_id: eventId, outcome_id: outcomeId }),
  })
  if (!response.ok) throw new Error(`Visitor launch failed: ${response.status}`)
  const { gameUrl } = await response.json() as { gameUrl: string }
  window.location.assign(gameUrl)
}

export function PredixEvents() {
  const options = useMemo(() => ({
    baseUrl: 'https://api.example.com/widget/v1',
    tokenProvider: requestWidgetToken,
    theme: 'system' as const,
    locale: 'zh-CN',
    oddsFormat: 'european' as const,
  }), [])

  return (
    <PredixProvider
      options={options}
      onOpen={openAsVisitor}
      onError={({ error }) => console.error(error)}
    >
      <PredixEventList limit={9} gridColumns={3} />
      <PredixEventCarousel
        preset="ending"
        limit={9}
        autoplay
        autoplayIntervalMs={4000}
        carouselStepItems={3}
        cardWidth={280}
      />
    </PredixProvider>
  )
}
```

无需 `useEffect`、手动注册、`await init` 或 `connect(root)`。Provider 同步渲染子组件并在后台初始化；包装层负责事件映射、卸载和 React StrictMode 清理。

仅在高级场景直接使用原生 `<predix-*>` 标签时，才需要额外导入 `predix-widget/register`。`import type {} from 'predix-widget/react'` 只启用 JSX 类型，不会注册组件。

### 1.2 原生 TypeScript / ESM

```bash
npm install predix-widget
```

导入 `register` 后，组件可直接写在页面中的目标位置，无需逐个注册或等待初始化：

```html
<predix-widget id="predix">
  <predix-event-list
    limit="9"
    grid-columns="3"
  ></predix-event-list>
  <predix-event-carousel
    preset="ending"
    limit="9"
    autoplay
    autoplay-interval-ms="4000"
    carousel-step-items="3"
    card-width="280"
  ></predix-event-carousel>
</predix-widget>

<script type="module">
  import 'predix-widget/register'

  // 留空表示使用当前 Host 的同源 BFF，推荐通过反向代理保持同源。
  const HOST_API_BASE_URL = ''
  const provider = document.querySelector('predix-widget')
  if (!provider) {
    throw new Error('Predix Widget provider not found')
  }

  async function requestWidgetToken() {
    const response = await fetch(`${HOST_API_BASE_URL}/api/predix/widget-token`, {
      method: 'POST',
      credentials: 'same-origin',
    })
    if (!response.ok) throw new Error(`Token request failed: ${response.status}`)
    const value = await response.json()
    if (
      typeof value?.token !== 'string'
      || !value.token
      || !Number.isFinite(value?.expiresIn)
      || value.expiresIn <= 0
    ) {
      throw new Error('Invalid Widget Token response')
    }
    return value
  }

  const options = {
    baseUrl: 'https://api.example.com/widget/v1',
    tokenProvider: requestWidgetToken,
    theme: 'system',
    locale: 'zh-CN',
    oddsFormat: 'european',
  }
  provider.options = options

  provider.addEventListener('open', async (event) => {
    // 会员启动：将此处改为 /api/predix/launch-game；
    // account 和 currency 应由 BFF 从服务端 Session 读取，不要从浏览器传入。
    const response = await fetch(`${HOST_API_BASE_URL}/api/predix/visit-game`, {
      method: 'POST',
      credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        event_id: event.detail.eventId,
        outcome_id: event.detail.outcomeId,
      }),
    })
    if (!response.ok) throw new Error(`Visitor launch failed: ${response.status}`)
    const { gameUrl } = await response.json()
    window.location.assign(gameUrl)
  })
</script>
```

EventCarousel 的 `card-width="280"` 用于设置轮播卡片宽度，也支持 `20rem`、`80vw` 或 `clamp(...)`。传入 `0`、负数或无效值时回退为 `280px`；响应式布局还会根据容器和可见卡片数提高实际宽度，因此卡片不会被压缩到组件安全下限以下。EventList 的列宽只由容器宽度和 `grid-columns` 共同决定。

需要自行管理作用域和生命周期时，可复用上方定义的 `options`：

```ts
import 'predix-widget/register'
import { PredixWidget } from 'predix-widget'

const installation = PredixWidget.install(options, { scope: document })
installation.ready.then((widget) => console.log(widget.config))
installation.setLocale('en')
installation.setTheme('system')

window.addEventListener('beforeunload', () => installation.destroy(), { once: true })
```

现有 `await PredixWidget.init(options)` 和 `widget.connect(root)` 继续兼容。也可以不导入 `register`，改为显式调用所需组件的 `define*()`。

#### 1.2.1 原生 HTML + CDN（无需安装）

不使用 npm 或构建工具时，可在 HTML 中直接加载固定版本的 IIFE 文件。脚本会自动注册所有 `<predix-*>` 组件：

```html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Predix Widget CDN 示例</title>
  </head>
  <body>
    <predix-widget id="predix">
      <predix-event-list
        limit="9"
        grid-columns="3"
      ></predix-event-list>
      <predix-event-carousel
        preset="ending"
        limit="9"
        autoplay
        autoplay-interval-ms="4000"
        carousel-step-items="3"
        card-width="280"
      ></predix-event-carousel>
    </predix-widget>

    <script src="https://widget.predix.cc/widget/0.1.0/predix-widget.global.js"></script>
    <script>
      // 留空表示使用当前 Host 的同源 BFF，推荐通过反向代理保持同源。
      const HOST_API_BASE_URL = ''
      const provider = document.querySelector('#predix')
      if (!(provider instanceof HTMLElement)) {
        throw new Error('Predix Widget provider not found')
      }

      async function requestWidgetToken() {
        const response = await fetch(`${HOST_API_BASE_URL}/api/predix/widget-token`, {
          method: 'POST',
          credentials: 'same-origin',
          headers: { Accept: 'application/json' },
        })
        if (!response.ok) throw new Error(`Token request failed: ${response.status}`)
        const value = await response.json()
        if (
          typeof value?.token !== 'string' ||
          !value.token ||
          !Number.isFinite(value?.expiresIn) ||
          value.expiresIn <= 0
        ) {
          throw new Error('Invalid Widget Token response')
        }
        return value
      }

      provider.options = {
        baseUrl: 'https://api.example.com/widget/v1',
        tokenProvider: requestWidgetToken,
        theme: 'system',
        locale: 'zh-CN',
        oddsFormat: 'european',
      }

      provider.addEventListener('ready', (event) => {
        console.log('Predix Widget ready', event.detail.sdkVersion)
      })
      provider.addEventListener('open', (event) => {
        openAsVisitor(event.detail).catch(console.error)
      })
      provider.addEventListener('error', (event) => {
        console.error(event.detail.error)
      })

      async function openAsVisitor({ eventId, outcomeId }) {
        // 会员启动：将此处改为 /api/predix/launch-game；
        // account 和 currency 应由 BFF 从服务端 Session 读取，不要从浏览器传入。
        const response = await fetch(`${HOST_API_BASE_URL}/api/predix/visit-game`, {
          method: 'POST',
          credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ event_id: eventId, outcome_id: outcomeId }),
        })
        if (!response.ok) throw new Error(`Visitor launch failed: ${response.status}`)
        const { gameUrl } = await response.json()
        window.location.assign(gameUrl)
      }
    </script>
  </body>
</html>
```

CDN 地址和 Widget API 地址是两个独立配置。内网环境可分别替换为：

```text
脚本：https://widget.predix-internal.com/widget/0.1.0/predix-widget.global.js
API：https://api.predix-internal.com/widget/v1
```

同一页面只加载一个 Widget 脚本，且 `baseUrl`、Widget Token 必须属于同一环境。`/api/predix/widget-token` 应由 Host 后端签发短期 Token；不要在 HTML 中写入 Agent API Key 或 Security Key。

### 1.3 Vue 3 + TypeScript

需要 Vue 3.3 或更高版本。

```bash
npm install predix-widget vue
```

```vue
<script setup lang="ts">
import {
  PredixEventCarousel,
  PredixEventList,
  PredixProvider,
} from 'predix-widget/vue'

// 留空表示使用当前 Host 的同源 BFF，推荐通过反向代理保持同源。
const HOST_API_BASE_URL = ''

async function requestWidgetToken() {
  const response = await fetch(`${HOST_API_BASE_URL}/api/predix/widget-token`, {
    method: 'POST',
    credentials: 'same-origin',
  })
  if (!response.ok) throw new Error(`Token request failed: ${response.status}`)
  const value: unknown = await response.json()
  const result = value as { token?: unknown; expiresIn?: unknown }
  if (
    typeof result.token !== 'string'
    || !result.token
    || !Number.isFinite(result.expiresIn)
    || Number(result.expiresIn) <= 0
  ) {
    throw new Error('Invalid Widget Token response')
  }
  return { token: result.token, expiresIn: Number(result.expiresIn) }
}

const options = {
  baseUrl: 'https://api.example.com/widget/v1',
  tokenProvider: requestWidgetToken,
  theme: 'system' as const,
  locale: 'zh-CN',
  oddsFormat: 'european' as const,
}

async function handleOpen({ eventId, outcomeId }: {
  eventId: string
  outcomeId?: string
}) {
  // 会员启动：将此处改为 /api/predix/launch-game；
  // account 和 currency 应由 BFF 从服务端 Session 读取，不要从浏览器传入。
  const response = await fetch(`${HOST_API_BASE_URL}/api/predix/visit-game`, {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ event_id: eventId, outcome_id: outcomeId }),
  })
  if (!response.ok) throw new Error(`Visitor launch failed: ${response.status}`)
  const { gameUrl } = await response.json() as { gameUrl: string }
  window.location.assign(gameUrl)
}

function handleError({ error }: { error: unknown }) {
  console.error(error)
}
</script>

<template>
  <PredixProvider :options="options" @open="handleOpen" @error="handleError">
    <PredixEventList :limit="9" :grid-columns="3" />
    <PredixEventCarousel
      preset="ending"
      :limit="9"
      autoplay
      :autoplay-interval-ms="4000"
      :carousel-step-items="3"
      card-width="280"
    />
  </PredixProvider>
</template>
```

无需配置 `isCustomElement`、手动注册、`await init` 或 `connect(root)`。包装组件支持 `@ready`、`@open` 和 `@error`，卸载清理由包装层处理。

需要直接使用原生标签时，需配置 Vue `isCustomElement`、导入 `predix-widget/register`，并可通过 `import type {} from 'predix-widget/vue'` 启用全局组件类型。

## 2. 获取 Widget Token

### 2.1 Widget Token 与 Player 登录的区别

“获取 Token”和“游客启动”不是两种可互换的 Widget 登录方式，而是两个不同阶段：

- **Widget Token 鉴权**：所有接入方式都需要。它只授权 Widget SDK 读取赛事、盘口和实时数据，代表 Agent 接入方，不代表某位会员，也不能用于打开 Player、下注或钱包交易。
- **游客启动**：用户点击赛事后，由 Host BFF 调用 `POST /agent/v1/visit_game` 创建匿名 Player 会话。它不要求会员账号或币种，适合公开预览、试玩或未登录用户。
- **会员启动**：用户点击赛事后，由 Host BFF 根据已经验证的 Host Session 调用 `POST /agent/v1/launch_game`。它需要 `account` 和 `currency`，用于关联会员身份、钱包、限额及后续业务记录。

因此，无论最终使用游客还是会员 Player，初始化 Widget 时都必须提供 `tokenProvider`。Widget Token、游客启动凭证和会员启动凭证用途不同，不得互换。

### 2.2 实现 `tokenProvider`

`tokenProvider` 是必填初始化参数。它不是 Token 字符串，而是一个按需获取 Token 的异步方法。浏览器只调用 Agent 自己的同源 BFF，不得直接调用 Predix Agent API：

```ts
// 留空表示使用当前 Host 的同源 BFF，推荐通过反向代理保持同源。
const HOST_API_BASE_URL = ''

async function requestWidgetToken(): Promise<{ token: string; expiresIn: number }> {
  const response = await fetch(`${HOST_API_BASE_URL}/api/predix/widget-token`, {
    method: 'POST',
    credentials: 'same-origin',
    headers: { Accept: 'application/json' },
  })
  if (!response.ok) throw new Error(`Token request failed: ${response.status}`)

  const value: unknown = await response.json()
  const tokenResult = value as { token?: unknown; expiresIn?: unknown }
  if (
    typeof tokenResult?.token !== 'string' ||
    !tokenResult.token ||
    !Number.isFinite(tokenResult?.expiresIn) ||
    Number(tokenResult.expiresIn) <= 0
  ) {
    throw new Error('Invalid Widget Token response')
  }
  return { token: tokenResult.token, expiresIn: Number(tokenResult.expiresIn) }
}
```

在 React、Vue 或原生初始化配置中直接传入该方法：

```ts
const options = {
  baseUrl: 'https://api.example.com/widget/v1',
  tokenProvider: requestWidgetToken,
}
```

SDK 会在初始化和 Token 即将过期时调用此方法。方法每次都必须返回当前有效的 `{ token, expiresIn }`，其中 `expiresIn` 是正数秒数。

以下 Node.js + Express BFF 示例在服务端使用 Agent API Key 和 Security Key 获取 Token，并将 Agent API 的 `expires_in` 映射为前端需要的 `expiresIn`：

```ts
import { createHmac, randomBytes } from 'node:crypto'
import express from 'express'

const app = express()
const agentApiUrl = requiredEnv('PREDIX_AGENT_API_URL')
const apiKey = requiredEnv('PREDIX_AGENT_API_KEY')
const securityKey = requiredEnv('PREDIX_AGENT_SECURITY_KEY')
const issueTokenPath = '/agent/v1/issue_widget_token'

function requiredEnv(name: string): string {
  const value = process.env[name]
  if (!value) throw new Error(`${name} is required`)
  return value
}

app.post('/api/predix/widget-token', async (_request, response) => {
  response.set('Cache-Control', 'no-store')
  try {
    const timestamp = Math.floor(Date.now() / 1000).toString()
    const nonce = randomBytes(16).toString('hex')
    const signaturePayload = `POST${issueTokenPath}${timestamp}${nonce}`
    const signature = createHmac('sha256', securityKey)
      .update(signaturePayload, 'utf8')
      .digest('hex')

    const upstream = await fetch(new URL(issueTokenPath, agentApiUrl), {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'x-api-key': apiKey,
        'x-timestamp': timestamp,
        'x-nonce': nonce,
        'x-signature': signature,
      },
    })
    const result: unknown = await upstream.json()
    const envelope = result as {
      code?: unknown
      data?: { token?: unknown; expires_in?: unknown }
    }
    if (
      !upstream.ok ||
      envelope?.code !== 0 ||
      typeof envelope?.data?.token !== 'string' ||
      !envelope.data.token ||
      !Number.isFinite(envelope?.data?.expires_in) ||
      Number(envelope.data.expires_in) <= 0
    ) {
      throw new Error('Invalid Agent API Widget Token response')
    }

    response.json({
      token: envelope.data.token,
      expiresIn: Number(envelope.data.expires_in),
    })
  } catch {
    response.status(502).json({ error: 'widget_token_unavailable' })
  }
})

app.listen(3000)
```

Agent API Key、Security Key、HMAC 签名和完整上游响应不得进入浏览器或日志。BFF 应配置 Session、CSRF 防护、限流和请求合并；成功与错误响应均设置 `Cache-Control: no-store`。

- SDK 根据 `expiresIn` 自动安排刷新，并在页面重新可见且到达刷新时间时立即尝试刷新。
- 首次 Token 鉴权失败时，SDK 刷新 Token 并只重试一次。
- `<predix-widget>` 不支持 `token` attribute，初始化 `options` 也不接受 `token`。
- `widget.setToken(token)` 和 `installation.setToken(token)` 仅用于运行时主动更新。

快速开始中的每个示例都已内置带响应校验的 `requestWidgetToken`，无需额外创建工具文件。生产环境还应增加请求合并、限流和统一错误处理。

### 2.3 游客与会员启动方式

两种 Player 启动方式都应在 Host 收到组件的 `open` 事件后执行，并且都必须经过 Host 自己的同源 BFF：

**游客启动**

1. 浏览器向 `/api/predix/visit-game` 提交 `event_id` 和可选的 `outcome_id`。
2. BFF 补充 `device`、`theme`、`language` 等展示参数，使用服务端保存的 Agent 凭据签名调用 `POST /agent/v1/visit_game`。
3. BFF 只把返回的 `game_url` 映射为 `{ gameUrl }` 返回浏览器，浏览器随后打开该地址。

游客流程不提交 `account` 或 `currency`，也不应把浏览器传入的身份字段转发给 Agent API。第一章的所有快速开始均采用这一方式。

**会员启动**

1. 浏览器向 `/api/predix/launch-game` 提交赛事深链参数，不提交可信会员身份。
2. BFF 验证当前 Host Session，从服务端会话或会员数据库读取 `account` 和 `currency`。
3. BFF 使用 Agent 凭据签名调用 `POST /agent/v1/launch_game`，再将 `{ gameUrl }` 返回浏览器。

不要从浏览器请求体直接信任 `account`、`currency`、Agent API Key 或 Security Key。未登录或 Session 失效时，BFF 应返回 `401`，由 Host 决定跳转登录页还是降级为游客模式。

Host 可按业务状态选择端点：

```ts
// 留空表示使用当前 Host 的同源 BFF，推荐通过反向代理保持同源。
const HOST_API_BASE_URL = ''

async function openPlayer(
  detail: { eventId: string; outcomeId?: string },
  isMemberLoggedIn: boolean,
) {
  const path = isMemberLoggedIn
    ? '/api/predix/launch-game'
    : '/api/predix/visit-game'
  const endpoint = `${HOST_API_BASE_URL}${path}`
  const response = await fetch(endpoint, {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      event_id: detail.eventId,
      outcome_id: detail.outcomeId,
    }),
  })
  if (!response.ok) throw new Error(`Player launch failed: ${response.status}`)
  const { gameUrl } = await response.json() as { gameUrl: string }
  window.location.assign(gameUrl)
}
```

## 3. 前端 API 文档

### 3.1 初始化参数

```ts
interface PredixWidgetInitOptions {
  baseUrl: string
  realtimeUrl?: string
  tokenProvider: () => Promise<{ token: string; expiresIn: number }>
  theme?: 'light' | 'dark' | 'system'
  locale?: string
  timezone?: string
  oddsFormat?: 'european' | 'american' | 'asia'
  fetch?: typeof globalThis.fetch
  requestTimeoutMs?: number
  cacheTtlMs?: number
  sessionCache?: boolean
}
```


| 参数                 | 必填       | 默认值                | 说明                                                |
| ------------------ | -------- | ------------------ | ------------------------------------------------- |
| `baseUrl`          | 是        | —                  | Widget API URL；可以是服务 Origin 或已包含 `/widget/v1`     |
| `realtimeUrl`      | 否        | —                  | 需要 WebSocket 实时推送时配置；未配置时仍使用 HTTP 快照             |
| `tokenProvider`    | 是        | —                  | 从 Agent BFF 获取并返回 `{ token, expiresIn }`；响应必须经过校验 |
| `theme`            | 否        | 平台默认主题             | `light`、`dark` 或 `system`                         |
| `locale`           | 否        | Host、浏览器或平台默认语言    | BCP 47 语言标签                                       |
| `timezone`         | 否        | 浏览器 IANA 时区        | 只影响展示                                             |
| `oddsFormat`       | 否        | `european`         | `european`、`american` 或 `asia`                    |
| `fetch`            | 否        | `globalThis.fetch` | 自定义 Fetch 实现                                      |
| `requestTimeoutMs` | 否        | `10000`            | HTTP 请求超时毫秒数                                      |
| `cacheTtlMs`       | 否        | `5000`             | 内存与 session 缓存有效期                                 |
| `sessionCache`     | 否        | `true`             | 是否通过 `sessionStorage` 跨页面复用短期响应                   |


### 3.2 Provider 与生命周期

优先使用 `<predix-widget>`、React `PredixProvider` 或 Vue `PredixProvider`。Provider 负责配置、鉴权、主题和子组件作用域。

底层生命周期：

```text
PredixWidget.init(options)
→ const disconnectRoot = widget.connect(root)
→ widget.setToken(token) / widget.setLocale(locale) / widget.setTheme(theme)
→ disconnectRoot()
→ widget.destroy()
```

- `PredixWidget.init(options)`：异步创建 SDK 实例。
- `widget.connect(root)`：连接 root 内组件，返回幂等清理函数。
- `widget.setToken(token)`：仅用于运行时更新 Token；后续请求和 Realtime 使用新 Token。
- `widget.setLocale(locale)`：更新未设置局部语言的组件。
- `widget.setTheme(theme)`：切换 `light`、`dark` 或 `system`，无需重建实例。
- `widget.destroy()`：永久停止实例，销毁后不可复用。
- `PredixWidget.install(options, { scope })`：同步返回 `PredixInstallation`，提供 `ready`、`subscribe()`、`setLocale()`、`setTheme()`、`setToken()`、`retry()` 和 `destroy()`；其中 `installation.setToken()` 也仅用于运行时更新。

`setToken()` 不会替代 `tokenProvider` 的有效期与自动刷新策略；常规接入应始终由 `tokenProvider` 管理 Token。

当前不提供全局 `setTimezone()` 或 `setOddsFormat()`；应在初始化时设置，或使用组件属性覆盖。

### 3.3 组件

#### `<predix-event-list>`

固定 grid 布局的展示型赛事列表。

```html
<predix-event-list
  preset="featured"
  category="sports"
  tag="football"
  limit="12"
  card-variant="compact"
  grid-columns="3"
></predix-event-list>
```


| 属性             | 默认值            | 合法值或说明                                |
| -------------- | -------------- | ------------------------------------- |
| `preset`       | —              | `featured`、`volume`、`latest`、`ending` |
| `category`     | —              | 稳定分类编码                                |
| `tag`          | —              | 固定标签编码                                |
| `limit`        | `12`           | 1–24                                  |
| `card-variant` | `standard`     | `standard`、`compact`、`hero`           |
| `grid-columns` | `3`            | 1–12，窄容器自动降为单列                        |
| `locale`       | SDK locale     | BCP 47                                |
| `timezone`     | SDK timezone   | IANA 时区                               |
| `odds-format`  | SDK oddsFormat | `european`、`american`、`asia`          |


`preset` 可与 `category`、`tag` 组合。`<predix-event-list>` 始终以 grid 展示；卡片列宽由组件可用宽度和 `grid-columns` 计算，不支持 `card-width`。

#### `<predix-event-carousel>`

横向轮播赛事列表。

```html
<predix-event-carousel
  preset="featured"
  category="sports"
  tag="football"
  limit="12"
  card-variant="standard"
  autoplay
  autoplay-interval-ms="4000"
  carousel-step-items="3"
  card-width="280"
></predix-event-carousel>
```


| 属性                     | 默认值            | 合法值或说明                                   |
| ---------------------- | -------------- | ---------------------------------------- |
| `preset`               | —              | `featured`、`volume`、`latest`、`ending`    |
| `category`             | —              | 稳定分类编码                                   |
| `tag`                  | —              | 固定标签编码                                   |
| `limit`                | `12`           | 1–24                                     |
| `card-variant`         | `standard`     | `standard`、`compact`、`hero`              |
| `autoplay`             | `false`        | 是否自动轮播                                   |
| `autoplay-interval-ms` | `4000`         | 不小于 1000                                 |
| `carousel-step-items`  | `3`            | 每次移动的卡片数                                 |
| `card-width`           | `280px`        | 轮播卡片宽度；非正数或无效值回退为 `280px`，实际宽度受响应式安全下限约束 |
| `locale`               | SDK locale     | BCP 47                                   |
| `timezone`             | SDK timezone   | IANA 时区                                  |
| `odds-format`          | SDK oddsFormat | `european`、`american`、`asia`             |


`preset` 的语义与赛事网格一致，可与 `category`、`tag` 组合。轮播在 `prefers-reduced-motion: reduce` 下停止自动播放。

#### `<predix-event-card>`

基础赛事卡片，通常由赛事网格或轮播组件创建。直接使用时通过 JavaScript property 传入 `event`：

```ts
const card = document.querySelector('predix-event-card')
card.event = event
card.variant = 'compact'
```

支持 `event`、`variant`、`locale`、`timezone` 和 `odds-format`。

布尔 HTML attribute 遵循标准 HTML 规则：属性存在即为 `true`，移除即为 `false`。不要写 `autoplay="false"`。

### 3.4 事件

原生组件发送以下可冒泡、可穿透 Shadow DOM 的 `CustomEvent`：


| 事件                            | `detail`                  | 用途                   |
| ----------------------------- | ------------------------- | -------------------- |
| `predix:ready`                | `{ sdkVersion }`          | 组件首次数据请求流程结束         |
| `predix:event-open-requested` | `{ eventId, outcomeId? }` | 请求 Host 打开赛事或 Player |
| `predix:error`                | `{ error }`               | 报告可供 Host 降级或记录的错误   |


`predix:ready` 可能在首次请求成功、为空或失败后发送，不代表请求一定成功。通过 `event.target` 区分组件实例。

`outcomeId` 是 Player 深链使用的 `market.id`，不是下注或授权凭证，也不同于 Realtime 数据中的 `market.outcomeId`。

框架包装组件提供更自然的事件 API：

- React：`onReady`、`onOpen`、`onError`
- Vue：`@ready`、`@open`、`@error`

Provider 与赛事子组件都支持上述框架事件；绑定在 Provider 上便于集中处理，绑定在子组件上便于区分局部行为。

### 3.5 主题与本地化

主题优先级：

```text
显式 theme: light / dark / system
→ 平台配置的默认主题
→ light
```

`system` 跟随 `prefers-color-scheme` 并响应系统主题变化。主题是 Provider/实例级配置，子组件不提供独立 `theme` 属性。

语言优先级：

```text
组件 locale
→ SDK init()/setLocale()
→ document.documentElement.lang
→ navigator.language
→ 平台默认语言
```

可通过公开的 `--predix-*` CSS Custom Properties 定制颜色、字体、间距、圆角和卡片宽度：

```css
#predix {
  --predix-color-accent: #1463ff;
  --predix-radius-md: 10px;
}
```

CSS 变量可设置在 `<predix-widget>` Provider（快速开始中的 `#predix`）或其祖先节点上。

不要依赖 Shadow DOM 内部 class、元素层级或未公开的 `::part()`。

### 3.6 React 与 Vue 包装 API

React 和 Vue 均导出：

- `PredixProvider`
- `PredixEventList`
- `PredixEventCarousel`
- `PredixEventCard`

框架包装层同时支持符合框架习惯的 camelCase 属性：


| HTML attribute                | React / Vue 属性       |
| ----------------------------- | -------------------- |
| `odds-format`                 | `oddsFormat`         |
| `card-variant`                | `cardVariant`        |
| `autoplay-interval-ms`        | `autoplayIntervalMs` |
| `carousel-step-items`         | `carouselStepItems`  |
| `grid-columns`                | `gridColumns`        |
| `card-width`（仅 EventCarousel） | `cardWidth`          |


## 4. 其它

### 4.1 缓存与 Realtime

- 默认缓存同时使用内存和 `sessionStorage`，只保存短期 API 响应，不保存 Token 明文。
- 缓存命名空间由 API URL 和 Token SHA-256 指纹组成，不同凭证不会共享数据。
- 配置 `realtimeUrl` 后，SDK 自动管理 WebSocket、订阅和重连。
- 单连接最多订阅 300 个 Outcome；超出部分保留最后 HTTP 数据。

完整 HTML 页面跳转会重建框架、DOM、SDK 实例和实时连接，但同一标签页可复用未过期的 session 缓存。

### 4.2 加载方式

#### npm / ESM

框架应用使用 `predix-widget/react` 或 `predix-widget/vue`。原生应用可导入 `predix-widget/register` 自动注册全部组件，或从 `predix-widget` 调用所需的 `define*()`。
生产安装应锁定交付清单指定的精确版本，不依赖 npm `latest`、`next` 等可变 dist-tag。

#### CDN IIFE

完整接入示例和环境地址见[原生 HTML + CDN](#121-原生-html--cdn无需安装)。IIFE 另通过全局 `PredixWidget` 暴露 `init` 和 `install`。

Predix 交付 CDN 使用 `/widget/{SDK_VERSION}/predix-widget.global.js`；通过 unpkg 或 jsDelivr 安装 npm 包时则使用包内的 `dist/predix-widget.global.js`，两者不是同一发布路径。

#### CDN Loader

```html
<script src="{PREDIX_WIDGET_CDN_URL}/widget/loader/{LOADER_VERSION}/predix-widget-loader.global.js"></script>
<script>
  PredixWidgetLoader.load({
    version: '{SDK_VERSION}',
    cdnBase: '{PREDIX_WIDGET_CDN_URL}',
    onLoadError: ({ version, url, error }) => {
      console.error('Failed to load Predix Widget', { version, url, error })
    },
  }).catch((error) => {
    console.error('Widget load aborted', error)
  })
</script>
```

Loader 随后加载 `{PREDIX_WIDGET_CDN_URL}/widget/{SDK_VERSION}/predix-widget.global.js`。生产环境必须同时固定 Loader 和 SDK 版本，不使用 `latest`。

### 4.3 安全与兼容性

- Agent API Key 和 Security Key 只保存在 Agent BFF。
- 浏览器只持有短期 Widget Token。
- 所有外部通信使用 HTTPS/WSS。
- 不在 URL、日志、监控或错误上报中记录 Token。
- Widget Token 不可用于 Player 启动或交易。
- 生产 CSP 应明确放行 CDN、Widget API、Realtime WSS 和图片 Origin。

浏览器基线：

- Chrome / Edge 最新两个主版本
- Firefox 最新两个主版本
- Safari 和 iOS Safari 16.4+
- Agent 目标 Android WebView

### 4.4 故障排查

组件没有数据时：

1. 确认 Provider 已收到有效 `options`。
2. 确认 `tokenProvider` 调用同源 BFF，并返回非空 `token` 和正数 `expiresIn`。
3. 确认 BFF 已把 `expires_in` 映射为 `expiresIn`，且 `baseUrl` 与 Token 属于同一环境。
4. 检查 `predix:error` / `onError` / `@error`。
5. 检查 CSP、CORS、BFF HTTP/业务错误和 Token 有效期。
6. 确认 `preset`、`category`、`tag` 条件下存在可见赛事。

赔率不再更新时：

1. 确认 `realtimeUrl` 正确。
2. 检查 CSP `connect-src` 是否放行 WSS。
3. 检查 Token 状态和浏览器网络连接。
4. 确认页面未超过 300 个 Outcome 的订阅上限。

### 4.5 延伸文档

完整的 Agent BFF、HMAC、Player 启动、安全策略、错误码和联调验收说明，请参阅[《Predix Widget SDK Agent 接入手册》](https://github.com/PrediX-Root/predix-saas/blob/main/docs/api/predix-widget-sdk-r01-zh.md)。