# OpenIM HTML Chat Demo

基于普通 HTML 的 OpenIM 聊天示例，支持独立使用，也可作为插件嵌入任意系统（Web、Android WebView 等）。

## 特性

- 使用 `@openim/wasm-client-sdk` 实现登录、收消息、发文本
- 提供 `CustomerChatSDK` 高层 API，接入方只需引入 SDK 的 JS/CSS 并调用 `init()` 或 `boot()`
- 提供 `boot()` 懒加载入口，支持“点击按钮后再初始化”
- 插件内部自动完成 admin token / user token 获取（也可由接入方自行提供 token）
- 通过 iframe + `postMessage` 与宿主系统解耦，不依赖宿主技术栈

## 文件说明

| 文件 | 说明 |
|------|------|
| `chat-app.html` | 聊天应用本体，运行在 iframe 内 |
| `chat-plugin.js` | 插件加载器，对外暴露 `CustomerChatSDK` 与 `OpenIMChatPlugin` |
| `host-demo.html` | 宿主系统示例（使用 `OpenIMChatPlugin`，需自行准备 token） |
| `login.html` / `register.html` | 独立登录、注册页 |
| `public/openim/` | OpenIM WASM 资源（构建时自动复制） |
| `android-chat-webview-demo/` | Android WebView 嵌入示例（见同级目录） |

## 如何运行

```bash
npm install
npm run dev
```

访问：

- `http://127.0.0.1:3000/login.html` — 登录后进入宿主 Demo
- `http://127.0.0.1:3000/host-demo.html` — 宿主系统嵌入示例
- `http://127.0.0.1:3000/chat-app.html` — 聊天页（可单独调试）

生产构建：

```bash
npm run build
```

将 `dist` 目录部署到 Web 服务器，确保以下资源可访问：

- `/chat-plugin.js`
- `/chat-app.html`
- `/openim/*`（WASM 文件）
- `/sdk/customer-chat-sdk.umd.js`
- `/sdk/customer-chat-sdk.css`

## 打包并发布 npm 包

1. 修改 `package.json` 中的包名（建议改成你自己的 scope 包名，如 `@your-scope/user-chat-sdk`）。
2. 构建产物（会自动生成 SDK 及聊天页资源）：

```bash
npm run build
```

3. 本地验证包内容：

```bash
npm pack
```

4. 登录并发布：

```bash
npm login
npm publish --access public
```

发布后，使用方可以直接引用包内 `dist` 目录资源（JS/CSS/chat-app/openim 都在包内）。

## 第三方接入（推荐：CustomerChatSDK）

任意系统只需引入 `customer-chat-sdk.css` 与 `customer-chat-sdk.umd.js`，创建 SDK 实例并调用 `init()`。插件会自动调用 OpenIM 的 `/auth/get_admin_token` 和 `/auth/get_user_token` 完成鉴权（或使用你传入的 `authProvider/token`）。

### 最小示例

```html
<link rel="stylesheet" href="https://your-domain.com/sdk/customer-chat-sdk.css" />
<script src="https://your-domain.com/sdk/customer-chat-sdk.umd.js"></script>
<script>
  const sdk = window.CustomerChatSDK.create();

  // token 获取逻辑保持不变：这里仍然由插件内部自动获取
  sdk.boot({
    userID: "user_2",
    recvID: "kefu_2",
    adminUserID: "imAdmin",
    adminSecret: "openIM123",
    platformID: 5,
    assetBaseUrl: "https://your-domain.com/im-chat",
  });
</script>
```

> 推荐传 `assetBaseUrl`（如 `https://your-domain.com/im-chat`），SDK 会自动拼接 `chat-app.html` 并透传给 iframe 页面加载 `openim/*` 资源。`appUrl` 仍可手动覆盖。
>
> `apiAddr` / `wsAddr` 可不传，默认分别为：
> - `http://47.83.182.132:8082`
> - `ws://47.83.182.132:8081`

### 懒初始化示例（点击后再初始化）

适合不想在页面内手写“创建按钮 + 首次点击 init”逻辑的场景。调用 `boot()` 后，插件会先创建入口按钮，首次点击时再执行 `init()` 并打开聊天窗。

```html
<link rel="stylesheet" href="https://your-domain.com/sdk/customer-chat-sdk.css" />
<script src="https://your-domain.com/sdk/customer-chat-sdk.umd.js"></script>
<script>
  const sdk = window.CustomerChatSDK.create();

  sdk.boot({
    userID: "user_2",
    recvID: "kefu_2",
    adminUserID: "imAdmin",
    adminSecret: "openIM123",
    platformID: 5,
    assetBaseUrl: "https://your-domain.com/im-chat",
    launcher: {
      text: "聊天",
      loadingText: "连接中...",
      removeAfterInit: true
    },
    onBootError(err) {
      alert("聊天初始化失败: " + (err.message || String(err)));
    }
  });
</script>
```

也可以直接使用静态方法：

```javascript
window.CustomerChatSDK.boot({
  userID: "user_2",
  recvID: "kefu_2"
});
```

### 使用方接入（npm 包资源直连）

若已发布 npm 包，可直接通过 CDN 使用包内资源（无需自行拆静态资源）：

```html
<link rel="stylesheet" href="https://unpkg.com/your-package-name/dist/sdk/customer-chat-sdk.css" />
<script src="https://unpkg.com/your-package-name/dist/sdk/customer-chat-sdk.umd.js"></script>
<script>
  window.CustomerChatSDK.boot({
    userID: "user_2",
    recvID: "kefu_2",
    adminUserID: "imAdmin",
    adminSecret: "openIM123",
    platformID: 5,
    assetBaseUrl: "https://unpkg.com/your-package-name/dist"
  });
</script>
```

如需锁版本，替换为 `your-package-name@1.0.0`。

### init(options) 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `userID` | 是 | 当前用户的 OpenIM userID |
| `recvID` | 否 | 默认会话对象（如客服 ID） |
| `apiAddr` | 否 | OpenIM HTTP API 地址，默认 `http://47.83.182.132:8082` |
| `wsAddr` | 否 | OpenIM WebSocket 地址，默认 `ws://47.83.182.132:8081` |
| `adminUserID` | 否 | 管理员 ID，默认 `imAdmin` |
| `adminSecret` | 否 | 管理员密钥，默认 `openIM123` |
| `platformID` | 否 | 平台 ID，Web 默认 `5` |
| `token` | 否 | 若已有 token，直接传入，跳过自动获取 |
| `authProvider` | 否 | 自定义鉴权函数，见下文 |
| `assetBaseUrl` | 否 | 静态资源根路径（含 `chat-app.html` 与 `openim/*`，通常是 `.../dist`，不要填 `.../dist/assets`）；传入后自动生成 `appUrl` 并注入 iframe |
| `appUrl` | 否 | 聊天页 URL；嵌入模式建议加 `?appEmbed=1&autoLogin=1` |
| `appOrigin` | 否 | 聊天页域名，生产环境请写具体域名，避免 `*` |
| `width` / `height` | 否 | 聊天窗口尺寸，默认 380 × 620 |
| `autoOpen` | 否 | 初始化后是否自动打开，默认 `false` |
| `mount` | 否 | 挂载 DOM 节点，默认 `document.body` |
| `onEvent` | 否 | 接收聊天页事件的回调 |
| `onBootError` | 否 | `boot()` 首次初始化失败时的回调 |
| `launcher` | 否 | `boot()` 入口按钮配置（`text`、`loadingText`、`removeAfterInit`、`style`） |

### SDK 方法

`CustomerChatSDK.create()` 返回实例，需先 `await init()` 再调用其他方法：

| 方法 | 说明 |
|------|------|
| `init(options)` | 初始化插件并完成鉴权 |
| `boot(options)` | 自动创建入口按钮，首次点击时执行初始化并打开聊天 |
| `openChat()` | 打开聊天浮窗 |
| `closeChat()` | 关闭聊天浮窗 |
| `openConversation(recvID)` | 打开指定单聊会话 |
| `sendText(content)` | 发送文本消息 |
| `isInitialized()` | 是否已初始化 |
| `destroy()` | 销毁插件实例 |

`CustomerChatSDK` 还提供静态方法 `CustomerChatSDK.boot(options)`，等价于 `CustomerChatSDK.create().boot(options)`。

### 鉴权方式

**方式 A：插件自动换 token（适合 Demo / App 内嵌）**

传入 `userID`、`apiAddr`、`wsAddr` 及管理员凭证，插件内部自动请求 token：

```javascript
await sdk.init({
  userID: "user_2",
  recvID: "kefu_2",
  apiAddr: "https://im-test.incsapp.com/api",
  wsAddr: "wss://im-test.incsapp.com/msg_gateway",
  adminUserID: "imAdmin",
  adminSecret: "openIM123",
});
```

**方式 B：接入方自行提供 token（生产推荐）**

token 由接入方后端签发，前端不暴露 `adminSecret`：

```javascript
await sdk.init({
  recvID: "kefu_2",
  authProvider: async () => {
    const res = await fetch("/api/im/token");
    const data = await res.json();
    return {
      userID: data.userID,
      token: data.token,
      apiAddr: data.apiAddr,
      wsAddr: data.wsAddr,
    };
  },
  assetBaseUrl: "https://your-domain.com/im-chat",
  appUrl: "https://your-domain.com/im-chat/chat-app.html?appEmbed=1&autoLogin=1",
});
```

**方式 C：直接传入 token**

```javascript
await sdk.init({
  userID: "user_2",
  token: "token-from-backend",
  apiAddr: "https://im-test.incsapp.com/api",
  wsAddr: "wss://im-test.incsapp.com/msg_gateway",
  recvID: "kefu_2",
});
```

### App / WebView 嵌入模式

在 `appUrl` 中追加 `?appEmbed=1&autoLogin=1`：

- `appEmbed=1` — 隐藏聊天页内的登录表单和控制面板
- `autoLogin=1` — 收到初始化配置后自动连接

也可仅传 `assetBaseUrl`，SDK 会自动生成 `chat-app.html?assetBaseUrl=...`，聊天页据此加载对应目录下的 `openim/*`：

- 例如 `assetBaseUrl = https://your-domain.com/im-chat`
- 对应资源应可访问：`https://your-domain.com/im-chat/chat-app.html`
- 以及：`https://your-domain.com/im-chat/openim/openIM.wasm`

Android WebView 示例见 `../android-chat-webview-demo/app/src/main/assets/chat_embed.html`：

- `chat-plugin.js` 可放在 App 本地 assets 加载
- `chat-app.html` 从远程服务加载（需保持 user-chat 服务运行）
- 模拟器访问本机开发服务：`http://10.0.2.2:3000`

## 底层 API（OpenIMChatPlugin）

若接入方已有 token，也可直接使用底层插件 API：

```html
<script src="https://your-domain.com/chat-plugin.js"></script>
<script>
  const widget = OpenIMChatPlugin.create({
    appUrl: "https://your-domain.com/chat-app.html",
    appOrigin: "https://your-domain.com",
    config: {
      apiAddr: "https://im-test.incsapp.com/api",
      wsAddr: "wss://im-test.incsapp.com/msg_gateway",
      userID: "user_2",
      token: "token-from-backend",
      recvID: "kefu_2",
    },
    onEvent(event) {
      console.log("chat event =>", event);
    },
  });

  widget.open();
</script>
```

| 方法 | 说明 |
|------|------|
| `open()` | 打开聊天浮窗 |
| `close()` | 关闭聊天浮窗 |
| `destroy()` | 销毁插件 |
| `updateConfig(nextConfig)` | 更新初始化配置 |
| `openConversation(recvID)` | 打开指定单聊会话 |
| `sendText(content)` | 发送文本消息 |

## 事件协议（聊天页 → 宿主）

通过 `onEvent` 回调接收：

| 事件 | 说明 |
|------|------|
| `CHAT_READY` | 聊天页加载完成 |
| `CHAT_CONNECTING` | 正在连接 |
| `CHAT_CONNECTED` | 连接成功 |
| `CHAT_CONNECT_FAILED` | 连接失败 |
| `CHAT_TOKEN_EXPIRED` | token 过期 |
| `CHAT_UNREAD_UPDATE` | 未读数变化（窗口关闭时显示角标） |
| `CHAT_LOGOUT` | 已登出 |
| `CHAT_SENT` | 消息发送成功 |
| `CHAT_CLOSE` | 聊天窗口关闭 |

## 技术说明

- SDK：`@openim/wasm-client-sdk@3.8.3-patch.13`
- 构建工具：Vite（本地打包，不依赖 CDN 运行时解析）
- WASM 资源通过 `npm run copy:openim-assets` 复制到 `public/openim/`
- 不使用 `esm.sh` 等 CDN 加载 SDK，避免 `@openim/protocol` 版本漂移导致的 `PullOrder` 导出错

## 生产建议

- `appOrigin` 请配置为具体域名，避免使用 `*`
- 生产环境使用 HTTPS / WSS
- `adminSecret` 不要写在前端；改用 `authProvider` 由后端换 token
- 同一用户避免多端频繁登录，防止 token 被顶掉（1507 TokenNotExistError）
- 通过反向代理统一 CORS 策略
