# 小黑窝聊天室 · 插件开发者手册

> 版本：v1.4 ｜ 适用加载器版本：v=20260913+
> 最后更新：2026-07-27

本手册面向第三方插件开发者，详细介绍 `.ctpark` 插件文件格式、UI 入口配置、声明式权限系统、`ctx` 运行时 API、动态入口、悬浮按钮拖拽、插件生命周期，以及完整示例与最佳实践。

---

## 目录

1. [概述](#1-概述)
2. [快速开始](#2-快速开始)
3. [.ctpark 文件格式](#3-ctpark-文件格式)
4. [.mlpack 文件格式（推荐）](#35-mlpack-文件格式推荐)
5. [UI 入口配置](#4-ui-入口配置)
6. [权限系统](#5-权限系统)
7. [插件协议系统（双向验证）](#6-插件协议系统双向验证)
8. [主题插件](#65-主题插件)
9. [小程序模式](#7-小程序模式)
10. [ctx API 参考 — 基础能力](#8-ctx-api-参考--基础能力)
11. [ctx API 参考 — 受控能力（按权限）](#9-ctx-api-参考--受控能力按权限)
12. [动态入口 API](#10-动态入口-api)
13. [悬浮按钮拖拽与位置控制](#11-悬浮按钮拖拽与位置控制)
14. [插件生命周期](#12-插件生命周期)
15. [完整示例](#13-完整示例)
16. [最佳实践](#14-最佳实践)
17. [移动端触控适配](#15-移动端触控适配)
18. [调试技巧](#16-调试技巧)
19. [API 速查表](#17-api-速查表)
20. [HTML 转 CTPARK 工具](#18-html-转-ctpark-工具)

---

## 1. 概述

小黑窝聊天室插件系统采用 **声明式配置 + 受控能力 API** 的设计：

- **声明式入口**：在 `.ctpark` 文件的 `ui` 字段声明插件的界面入口（按钮位置、图标、标签），加载器自动创建按钮并挂载。
- **声明式权限**：在 `permissions` 字段声明插件需要的权限，`ctx` 上对应能力才会被挂载。未声明的权限，对应 API 为 `undefined`。
- **统一入口注册表**：静态入口（`.ctpark` 声明）和动态入口（运行时 `ctx.addEntry()`）共用同一套模型，自动聚合到插件中心与命令面板（Ctrl+K）。
- **资源托管**：通过 `ctx` 创建的定时器、监听器会在插件卸载/禁用时自动清理，无需手动管理。

插件文件扩展名为 `.ctpark`，内容是一个 JSON 对象。

---

## 2. 快速开始

最小可用的插件：

```json
{
  "id": "hello-world",
  "name": "Hello World",
  "version": "1.0.0",
  "ui": {
    "entry": "button",
    "mount": "floating",
    "label": "Hello",
    "icon": "fas fa-hand-wave",
    "action": "helloAction"
  },
  "js": "window.helloAction = function(ctx) { ctx.showToast('Hello, World!', 'success'); };"
}
```

将上述内容保存为 `hello-world.ctpark`，在聊天室左侧「插件中心」→ 底部「上传插件」选择该文件即可安装。

点击右下角出现的悬浮按钮，会弹出 toast 提示。

---

## 3. .ctpark 文件格式

`.ctpark` 是一个 JSON 文件，顶层字段如下：

| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 插件唯一标识。建议用小写字母+连字符，如 `my-plugin`。不能与已存在插件重复。 |
| `name` | string | 是 | 插件显示名称。 |
| `version` | string | 是 | 插件版本号，如 `1.0.0`。 |
| `description` | string | 否 | 插件描述，显示在插件中心卡片上。 |
| `author` | string | 否 | 作者名称。 |
| `permissions` | string[] | 否 | 声明的权限列表，决定 `ctx` 上挂载哪些受控能力。详见[第 5 节](#5-权限系统)。 |
| `ui` | object | 否 | UI 入口配置。详见[第 4 节](#4-ui-入口配置)。 |
| `css` | string | 否 | 插件 CSS 代码（内联模式），会注入到 `<style>` 标签。 |
| `js` | string | 否 | 插件 JavaScript 代码（内联模式），会注入到 `<script>` 标签。 |
| `cssFile` | string | 否 | 外部 CSS 文件名（外部文件模式），如 `"my-plugin.css"`。加载器从 `plugins/` 目录 fetch 加载。与 `css` 互斥，优先使用内联 `css`。 |
| `jsFile` | string | 否 | 外部 JS 文件名（外部文件模式），如 `"my-plugin.js"`。加载器从 `plugins/` 目录 fetch 加载。与 `js` 互斥，优先使用内联 `js`。 |
| `html` | string | 否 | 插件 modal 模式下的 HTML 内容。与 `mount: "modal"` 配合使用。 |
| `hooks` | object | 否 | 生命周期钩子，键为事件名，值为处理函数。详见[第 10 节](#10-插件生命周期)。 |
| `settings` | array | 否 | 插件自定义设置项，每项 `{ key, label, type, default }`。 |

### 命名约定

- `id`：小写字母、数字、连字符，如 `todo-list`、`weather-widget`。
- `version`：遵循语义化版本（SemVer），如 `1.0.0`、`0.2.1`。
- `action` 函数名：挂载到 `window` 上，建议用插件 id 作为前缀避免冲突，如 `todoListOpen`。

### CSS/JS 两种编写模式

插件支持两种方式编写 CSS 和 JS 代码：

**1. 内联模式**（`css` / `js` 字段）

将代码直接写在 `.ctpark` 的 JSON 字符串中。适合代码量小、需要单文件分发的插件。缺点是 JSON 字符串中的代码无语法高亮、无换行，可读性差。

```json
{
  "id": "mini-plugin",
  "css": ".my-box { color: red; }",
  "js": "window.myAction = function(ctx) { ctx.showToast('hi'); };"
}
```

**2. 外部文件模式**（`cssFile` / `jsFile` 字段）— 推荐

将 CSS 和 JS 拆分为独立文件，`.ctpark` 作为清单引用。代码有正常换行、语法高亮，开发体验好，适合中大型插件。

目录结构：
```
plugins/
  ├── my-plugin.ctpark    # 清单文件
  ├── my-plugin.css        # 样式
  └── my-plugin.js         # 脚本
```

`.ctpark` 内容：
```json
{
  "id": "my-plugin",
  "name": "我的插件",
  "version": "1.0.0",
  "ui": { ... },
  "cssFile": "my-plugin.css",
  "jsFile": "my-plugin.js"
}
```

加载器在启用插件时自动从 `plugins/` 目录 fetch 加载外部文件内容并注入。

**两种模式可混用**：若同时提供 `css` 和 `cssFile`，优先使用内联 `css`。未提供内联 `css` 时才加载 `cssFile`。

> **注意**：通过聊天室 UI 上传单个 `.ctpark` 文件安装的插件，外部 CSS/JS 文件需手动放到服务器 `plugins/` 目录下才能加载。外部文件模式主要面向开发者本地开发或预装插件。

---

## 3.5. .mlpack 文件格式（推荐）

`.mlpack` 是一种新的插件格式，本质是 ZIP 压缩包，支持将插件代码、样式和资源拆分为独立文件，提供更好的开发体验。

### 文件结构

```
my-plugin.mlpack (ZIP 压缩包)
├── manifest.json    # 插件清单（必需）
├── main.js          # 插件主脚本（必需）
├── style.css        # 插件样式（可选）
└── assets/          # 资源目录（可选）
    └── icon.png     # 插件图标
```

### manifest.json 字段

| 字段 | 类型 | 必需 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 插件唯一标识 |
| `name` | string | 是 | 插件显示名称 |
| `version` | string | 是 | 版本号 |
| `description` | string | 否 | 插件描述 |
| `author` | string | 否 | 作者名称 |
| `permissions` | string[] | 否 | 声明的权限列表 |
| `ui` | object | 否 | UI 入口配置（同 .ctpark） |
| `entry` | string | 否 | 入口函数名，如 `"myPluginInit"` |

### manifest.json 示例

```json
{
  "id": "my-mlpack-plugin",
  "name": "我的 MLPack 插件",
  "version": "1.0.0",
  "description": "使用 .mlpack 格式开发的插件",
  "author": "Developer",
  "permissions": ["storage", "clipboard"],
  "ui": {
    "entry": "button",
    "mount": "floating",
    "label": "MLPack",
    "icon": "fas fa-box-open"
  },
  "entry": "myPluginInit"
}
```

### main.js 示例

```javascript
function myPluginInit(ctx) {
    ctx.showToast('Hello from .mlpack!', 'success');
    
    ctx.openFloatingCard({
        id: 'my-card',
        title: 'MLPack Demo',
        width: 320,
        height: 200,
        content: '<div style="padding:20px;text-align:center;">' +
            '<div style="font-size:48px;margin-bottom:16px;">🎉</div>' +
            '<div style="color:var(--text-color);">MLPack 格式插件</div>' +
            '</div>'
    });
}
```

### 优势

1. **代码独立**：JS/CSS 为独立文件，支持语法高亮和格式化
2. **资源管理**：`assets/` 目录可存放图标、图片、音频等资源
3. **图片图标支持**：`assets/icon.png` 会自动作为插件图标
4. **单文件分发**：ZIP 打包后仍是单个文件，便于上传和分享

### 创建 .mlpack 插件

**方法 1：手动打包**

使用任何 ZIP 工具将文件打包，扩展名改为 `.mlpack`。

**方法 2：Node.js 脚本**

```javascript
const JSZip = require('jszip');
const fs = require('fs');

const zip = new JSZip();
zip.file('manifest.json', fs.readFileSync('manifest.json', 'utf-8'));
zip.file('main.js', fs.readFileSync('main.js', 'utf-8'));
zip.file('style.css', fs.readFileSync('style.css', 'utf-8'));
zip.folder('assets').file('icon.png', fs.readFileSync('assets/icon.png'));

zip.generateNodeStream({ type: 'nodebuffer' })
    .pipe(fs.createWriteStream('my-plugin.mlpack'));
```

---

## 4. UI 入口配置

`ui` 字段定义插件的界面入口（按钮）。加载器根据配置自动创建按钮并挂载到对应位置。

### 字段说明

| 字段 | 类型 | 必需 | 默认值 | 说明 |
|---|---|---|---|---|
| `entry` | string | 是 | — | 入口类型。固定为 `"button"`（兼容值：`"sidebar-button"`、`"toolbar-button"`）。 |
| `mount` | string | 否 | `"toolbar"` | 挂载位置。见下表。 |
| `label` | string | 否 | 插件 name | 按钮标签文字。 |
| `icon` | string | 否 | — | Font Awesome 图标类名，如 `"fas fa-clock"`。 |
| `action` | string | 否 | — | 点击时调用的 `window` 函数名，签名为 `function(ctx, messageContext)`。 |
| `category` | string | 否 | 插件 name | 分类，用于命令面板分组。 |
| `order` | number | 否 | `0` | 排序权重，值越小越靠前。 |

### 挂载位置（mount）

| mount 值 | 位置 | 展现形式 | 说明 |
|---|---|---|---|
| `"toolbar"` | 导航栏右侧 | 图标按钮 | 适合高频功能入口。 |
| `"sidebar"` | 左侧边栏 | 图标 + 文字 | 适合常驻功能。 |
| `"floating"` | 屏幕右下角 | 圆角矩形悬浮按钮 | **支持拖拽与位置记忆**。适合快捷工具。 |
| `"panel"` | 插件中心抽屉内 | 列表项（图标+标签） | 不创建常驻按钮，仅在插件中心/命令面板出现。 |
| `"input-toolbar"` | 聊天输入工具栏 | 图标按钮 | 与表情/图片/文件按钮并列。适合聊天增强功能。 |
| `"context-menu"` | 消息右键菜单 | 菜单项 | 右键消息时出现。action 会收到 `messageContext`。 |
| `"modal"` | 居中模态框 | 无按钮，点击直接弹 modal | 配合 `html` 字段使用。 |

### action 调用约定

点击按钮时，加载器调用 `window[action](ctx, messageContext)`：

- `ctx`：运行时上下文对象，包含所有 API（详见[第 6-7 节](#6-ctx-api-参考--基础能力)）。
- `messageContext`：仅 `context-menu` 入口有值，包含 `{ element, messageId, message }`。

```javascript
// 在 .ctpark 的 js 字段中定义 action
window.myPluginOpen = function(ctx, msgCtx) {
    if (msgCtx) {
        // 来自右键菜单
        ctx.showToast('你右键了消息: ' + msgCtx.messageId, 'info');
    } else {
        // 来自普通按钮
        ctx.showToast('插件已打开', 'success');
    }
};
```

### 一个插件多个入口

`.ctpark` 的 `ui` 字段只能声明**一个**静态入口。如果需要多个入口，使用[动态入口 API](#8-动态入口-api) 在运行时注册：

```javascript
// js 字段中，首次 action 调用时注册其他入口
window.__myPluginInited = false;
window.myPluginOpen = function(ctx) {
    if (!window.__myPluginInited) {
        window.__myPluginInited = true;
        // 注册第二个悬浮按钮
        ctx.registerFloatingButton({
            id: 'second-btn',
            label: '第二个功能',
            icon: 'fas fa-bolt',
            action: function(c) { c.showToast('第二个功能', 'info'); }
        });
        // 注册输入栏按钮
        ctx.registerInputButton({
            id: 'input-btn',
            label: '输入增强',
            icon: 'fas fa-magic',
            action: function(c) { c.showToast('输入增强', 'info'); }
        });
    }
    ctx.showToast('主功能已打开', 'success');
};
```

---

## 5. 权限系统

### 设计理念

插件采用 **最小权限原则**。每个插件在 `permissions` 字段中声明所需权限，加载器只挂载声明的能力到 `ctx`。未声明的权限，对应 API 为 `undefined`。

这样做的好处：
- 用户可从插件中心卡片的权限徽章直观了解插件能力范围。
- 为未来的权限审批/管理面板打下基础。
- 限制插件能力范围，降低安全风险。

### 权限清单

| 权限名 | ctx API | 说明 |
|---|---|---|
| `"storage"` | `ctx.storage` | 命名空间隔离的本地存储 |
| `"network"` | `ctx.fetch` | 受控网络请求（默认 15s 超时） |
| `"notify"` | `ctx.notify` | 浏览器桌面通知 |
| `"clipboard"` | `ctx.clipboard` | 剪贴板读写 |
| `"audio"` | `ctx.playSound` | 音频播放（URL 或蜂鸣） |
| `"theme"` | `ctx.getTheme` / `ctx.onThemeChange` | 主题查询与变更监听 |
| `"interplugin"` | `ctx.sendToPlugin` 等 | 插件间通信 |
| `"timers"` | `ctx.setTimeout` 等 | 托管定时器（卸载自动清理） |
| `"dom"` | `ctx.getMountContainer` | 获取挂载点 DOM 元素 |

### 声明方式

```json
{
  "id": "my-plugin",
  "permissions": ["storage", "network", "notify", "timers"]
}
```

### 权限自省

插件可在运行时通过 `ctx.permissions` 查看自己声明的权限列表：

```javascript
window.myPluginOpen = function(ctx) {
    console.log('我拥有的权限:', ctx.permissions);
    if (ctx.permissions.includes('storage')) {
        ctx.storage.set('lastOpen', Date.now());
    }
};
```

---

## 6. 插件协议系统（双向验证）

### 概述

插件协议系统是一种基于 **HMAC-SHA256 签名**的双向身份验证机制。平台使用密钥对插件进行签名，插件加载时客户端使用相同密钥验证签名的合法性，从而实现**平台与插件的双向身份确认**。通过协议验证的插件获得**受信任状态**，可绕过部分安全检查，获得更高的执行权限。

**双向验证流程**：
1. **平台签名**：插件开发者向平台提交插件，平台使用密钥对插件进行签名
2. **客户端验证**：插件安装后，客户端使用内置密钥验证签名的合法性
3. **权限授予**：验证通过的插件获得受信任状态，自动获得全部权限并跳过语法检查

### 协议字段格式

在插件 manifest（`.ctpark` 的 JSON 根级 / `.mlpack` 的 `manifest.json`）中添加 `protocol` 字段：

```json
{
    "id": "my-plugin",
    "name": "我的插件",
    "version": "1.0.0",
    "protocol": {
        "v": 1,
        "sig": "HMAC-SHA256签名（64位hex字符串）"
    }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| v | number | 协议版本，当前为 `1` |
| sig | string | HMAC-SHA256 签名，hex 格式 |

### 签名生成算法

签名基于插件的三元组（`id + name + version`）使用 HMAC-SHA256 计算：

```
payload = id + "|" + name + "|" + version
signature = HMAC-SHA256(payload, SECRET_KEY)
```

#### Node.js 生成签名示例

```javascript
const crypto = require('crypto');
const SECRET = 'lxh-plugin-protocol-2026';

function generateSignature(id, name, version) {
    const payload = id + '|' + name + '|' + version;
    return crypto.createHmac('sha256', SECRET).update(payload, 'utf8').digest('hex');
}

const sig = generateSignature('my-plugin', '我的插件', '1.0.0');
console.log(sig);
```

#### 命令行生成签名

```bash
node -e "const c=require('crypto');console.log(c.createHmac('sha256','lxh-plugin-protocol-2026').update('my-plugin|我的插件|1.0.0','utf8').digest('hex'))"
```

### 受信任插件的特权

| 特权 | 普通插件 | 受信任插件 |
|------|---------|-----------|
| JS 语法检查 | 执行 `new Function()` 预检，语法错误会被拦截 | **跳过语法检查**，支持动态 importmap、module 脚本等高级语法 |
| 权限获取 | 仅获得 `permissions` 数组中声明的权限 | **自动获得所有权限**（storage、network、notify、clipboard、audio、theme、interplugin、timers、dom） |
| 插件中心标识 | 无 | 显示绿色盾牌标识 |
| ctx.isTrusted | `false` | `true` |

### 适用场景

- 使用 `<script type="module">` 和 `importmap` 的插件（如 3D 游戏引擎）
- 需要使用 ES Module `import`/`export` 语法的插件
- 需要全部权限但不想逐个声明的插件
- 需要动态注入 CDN 资源的插件

### 注意事项

1. 签名与三元组（id、name、version）绑定，修改任一字段需重新生成签名
2. 密钥 `lxh-plugin-protocol-2026` 为平台分配，请勿泄露
3. `.ctpark` 格式直接在 JSON 根级添加 `protocol` 字段
4. `.mlpack` 格式在 `manifest.json` 中添加 `protocol` 字段
5. 未通过协议验证的插件仍可正常使用，仅按普通权限模式运行

---

## 6.5 主题插件

主题插件是一类特殊插件：不提供任何功能入口，只通过 CSS 覆盖聊天室全局样式。它不写 `js`、不注册 `ui` 入口，核心就是一个带大段 `css` 的 `.ctpark` 文件。

### 声明方式

`manifest` 用 `category` 标记为主题类，上传到市场时也选"主题"分类：

```json
{
  "id": "my-neon-theme",
  "name": "霓虹主题",
  "version": "1.0.0",
  "category": "theme",
  "themeMode": "dark",
  "permissions": ["theme"],
  "css": "..."
}
```

### 锁定深浅模式

主题可声明 `themeMode: 'light' | 'dark'`，启用后加载器把全站强制切到对应模式，禁用时恢复用户原来的设置。整体走暗色系（或亮色系）的主题建议声明，免得浅色模式下漏网的写死白底组件穿帮。声明后用户在设置里手动切换深浅也会被主题压住，符合"颜色锁定"的预期。

### 生效规则（加载器内置，开发者无需处理）

- 主题插件**安装后默认关闭**，不会自动应用样式，必须到插件中心手动打开开关
- 同一时间**只允许启用一个**主题插件，打开新的会自动关闭旧的，避免样式打架
- 主题插件不创建 UI 入口，`ui` 字段会被忽略，请勿声明

### 改什么：两层 CSS 变量

聊天室配色全部走 CSS 变量，分两层：

- **设计 Token**（`--color-*`）：原始色板，品牌色、中性色、功能色
- **语义 Token**（`--bg-*`、`--text-*`、`--border`、`--primary` 等）：所有组件引用的这一层

主题只需覆盖这两层变量，全部组件跟着变。注意亮暗模式的变量定义选择器优先级相同，主题要同时覆盖三个选择器，否则切模式会失效：

```css
:root,
[data-theme="light"],
[data-theme="dark"] {
  --primary: #00C8F0;
  --bg-app: #0A0E1A;
  --text-primary: #E8F6FF;
  --border: rgba(0, 200, 240, 0.16);
}
```

### 压过设置面板的内联主题色

用户在设置里选的主题色，由 JS 以内联样式直接写到 `<html>` 上（`--primary`、`--primary-rgb`、`--primary-color` 等 8 个变量）。内联样式优先级高于外部 CSS，只覆盖 `:root` 的变量定义是压不掉的，实测会出现"背景变了但主色没变"的怪现象。这 8 个变量必须加 `!important`：

```css
--primary: #00C8F0 !important;
--primary-hover: #00E0FF !important;
--primary-active: #00A0C0 !important;
--primary-light: rgba(0, 200, 240, 0.15) !important;
--primary-dark: var(--primary-active) !important;
--primary-rgb: 0, 200, 240 !important;
--primary-color: var(--primary) !important;
--color-primary-rgb: 0, 200, 240 !important;
```

其余变量没有内联覆盖，普通写法就行。关闭主题后内联主题色自动恢复。

### 覆盖硬编码背景

少数组件背景是写死的（`navbar`、`sidebar`、消息气泡、弹窗等），变量管不到，需单独覆盖。暗色模式的选择器（如 `[data-theme="dark"] .navbar`）特异性更高，主题插件用 `!important` 压掉：

```css
.navbar {
  background: rgba(10, 14, 26, 0.92) !important;
}
.message.own .message-text {
  background: linear-gradient(135deg, rgba(0, 160, 190, 0.35), rgba(157, 77, 255, 0.3)) !important;
}
```

### 全局装饰

想加背景网格、霓虹发光、扫描线这类全局效果，直接作用在 `body` 或根元素上，注意 `pointer-events: none` 别挡操作：

```css
body {
  background-color: #0A0E1A;
  background-image: linear-gradient(rgba(0, 200, 240, 0.045) 1px, transparent 1px),
    linear-gradient(90deg, rgba(0, 200, 240, 0.045) 1px, transparent 1px);
  background-size: 32px 32px;
}
```

### 替换默认图片（mlpack 主题）

`.ctpark`（纯 JSON）塞不进二进制资源，但 **mlpack 格式**可以在 `assets/` 目录打包图片。主题插件用 `replaceImages` 字段把聊天室默认图片换成自己的，key 是 CSS 选择器，value 是 `assets/` 下的文件名（或直接写完整 URL）：

```json
{
  "category": "theme",
  "replaceImages": {
    ".logo-icon": "assets/logo.png",
    "#userAvatar": "assets/avatar.png"
  }
}
```

加载器启用主题时找到匹配元素替换 `src` / `background-image`，关闭时还原默认图。常用默认图的选择器：

| 选择器 | 说明 |
|--------|------|
| `.logo-icon` | 顶部 logo |
| `#userAvatar` | 侧边栏用户头像 |
| `.avatar-option img` | 头像选择器的 4 个默认头像 |
| `#privateAvatar` | 私信页头像 |

### 参考实现

插件市场"主题"分类自带「赛博朋克霓虹」主题（`cyberpunk-neon.ctpark`），解包看 `css` 字段就是完整的主题写法。

---

## 7. 小程序模式

### 概述

小程序模式为插件提供了一种**全屏沉浸式**的展示方式，类似微信小程序的体验。适用于内容较多、交互复杂的插件，尤其在手机端能获得更好的用户体验。

小程序模式有两种使用方式：

| 方式 | 配置 | 适用场景 |
|------|------|---------|
| **多端适配** | `type: "plugin"` + `ui.mobileMode: "miniprogram"` | 电脑端保持浮动按钮/模态框，手机端自动切换为小程序全屏 |
| **纯小程序** | `type: "miniprogram"` | 专门为移动端设计的插件，始终以小程序模式运行 |

### 多端适配版（推荐）

在 `ui` 配置中添加 `mobileMode: "miniprogram"`，插件在电脑端保持原有形态（浮动按钮、模态框等），在手机端点击时自动以小程序全屏模式打开。

```json
{
  "id": "my-plugin",
  "name": "我的插件",
  "version": "1.0.0",
  "type": "plugin",
  "ui": {
    "entry": "button",
    "mount": "floating",
    "label": "我的插件",
    "icon": "fas fa-rocket",
    "action": "myPluginOpen",
    "mobileMode": "miniprogram"
  },
  "js": "..."
}
```

**判断逻辑**：
- 电脑端：点击浮动按钮 → 执行 `ui.action` 函数 → 打开模态框（原有逻辑）
- 手机端：点击浮动按钮 → 直接以小程序全屏模式渲染 → 执行 JS 初始化

### 纯小程序版

设置 `type: "miniprogram"`，插件始终以小程序模式运行。这类插件会出现在侧边栏「小程序」抽屉面板中。

```json
{
  "id": "my-miniprogram",
  "name": "我的小程序",
  "version": "1.0.0",
  "type": "miniprogram",
  "ui": {
    "icon": "fas fa-mobile-alt",
    "action": "myMiniInit"
  },
  "tabBar": {
    "list": [
      { "text": "首页", "iconPath": "fas fa-home" },
      { "text": "发现", "iconPath": "fas fa-compass" },
      { "text": "我的", "iconPath": "fas fa-user" }
    ]
  },
  "pages": [
    { "content": "<div>首页内容</div>" },
    { "content": "<div>发现内容</div>" },
    { "content": "<div>我的内容</div>" }
  ],
  "js": "..."
}
```

**注意**：纯小程序插件在电脑端也可打开，但会提示「建议在手机端使用」。

### 小程序界面结构

小程序全屏容器包含以下部分：

```
┌─────────────────────────────┐
│  ←   小程序标题      ⋯  ✕   │  ← 顶部导航栏（48px）
├─────────────────────────────┤
│                             │
│                             │
│        页面内容区域          │  ← 可滚动内容区
│                             │
│                             │
├─────────────────────────────┤
│  首页   发现   我的          │  ← 底部 tabBar（可选，56px）
└─────────────────────────────┘
```

- **顶部导航栏**：左侧返回按钮、中间标题、右侧胶囊按钮（更多/关闭）
- **页面内容区**：插件的主要内容，支持滚动
- **底部 tabBar**：可选，用于多页面切换

### TabBar 多页面配置

如果插件有多个主要页面，可以配置 `tabBar` 实现底部标签切换。

```json
{
  "tabBar": {
    "color": "#999",
    "selectedColor": "#4caf50",
    "backgroundColor": "#fff",
    "list": [
      {
        "text": "首页",
        "iconPath": "fas fa-home"
      },
      {
        "text": "分类",
        "iconPath": "fas fa-th-large"
      },
      {
        "text": "我的",
        "iconPath": "fas fa-user"
      }
    ]
  },
  "pages": [
    { "content": "<div class='page-home'>首页</div>" },
    { "content": "<div class='page-category'>分类</div>" },
    { "content": "<div class='page-profile'>我的</div>" }
  ]
}
```

**tabBar.list 配置项**：

| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `text` | string | 是 | 标签文字 |
| `iconPath` | string | 是 | 图标。支持 FontAwesome 类名（如 `fas fa-home`）或图片 URL（`http://`、`data:` 开头） |

**pages 配置项**：

| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `content` | string | 是 | 页面 HTML 内容字符串 |
| `path` | string | 否 | 页面路径标识（预留） |

**注意**：
- `tabBar.list` 至少需要 2 个标签才会显示底部 tabBar
- `pages` 数组顺序与 `tabBar.list` 一一对应
- 单页面插件不需要配置 `tabBar` 和 `pages`，使用 `html` 或 `action` 渲染即可

### 小程序专属 API

小程序模式下，插件可以使用以下额外的 API：

```javascript
window.myPluginInit = function(ctx) {
    // 设置导航栏标题
    ctx.setNavigationBarTitle('新标题');

    // 显示/隐藏返回按钮（首页默认隐藏）
    ctx.showNavigationBarBackButton(true);

    // 切换 tabBar 页面（有 tabBar 时可用）
    ctx.switchTab(1);  // 切换到第二个标签

    // 关闭小程序
    ctx.closeMiniprogram();
};
```

| API | 说明 |
|-----|------|
| `ctx.setNavigationBarTitle(title)` | 设置导航栏标题文字 |
| `ctx.showNavigationBarBackButton(show)` | 显示或隐藏返回按钮（默认首页隐藏） |
| `ctx.switchTab(index)` | 切换到底部 tabBar 的第 index 个页面（从 0 开始） |
| `ctx.navigateTo(url)` | 预留接口：导航到新页面 |
| `ctx.navigateBack(delta)` | 预留接口：返回上一页 |
| `ctx.closeMiniprogram()` | 关闭小程序 |

### 小程序入口

小程序插件可以从以下入口打开：

1. **侧边栏小程序按钮**：点击侧边栏底部的「小程序」按钮，打开抽屉面板，显示所有小程序插件
2. **浮动按钮**：多端适配版插件的浮动按钮，手机端点击自动进入小程序模式
3. **插件中心**：在插件中心点击小程序类型的插件，也会以小程序模式打开

### 开发建议

1. **优先考虑多端适配**：除非插件完全是移动端专属，否则建议使用 `ui.mobileMode: "miniprogram"` 做多端适配
2. **响应式设计**：小程序占满整个屏幕，插件内容应使用相对单位（%、vw、vh）适配不同屏幕
3. **安全区域**：底部 tabBar 已自动适配 iPhone 安全区域（`env(safe-area-inset-bottom)`）
4. **性能优化**：小程序模式下内容较多，注意图片懒加载和 DOM 节点控制
5. **顶部间距**：内容区顶部不需要额外 padding，导航栏已占固定高度

### 与模态框模式对比

| 特性 | 模态框（modal） | 小程序（miniprogram） |
|------|----------------|----------------------|
| 展示方式 | 居中弹窗，带遮罩 | 全屏从底部滑上 |
| 顶部导航 | 模态框标题栏 + 关闭按钮 | 完整导航栏（返回/标题/胶囊） |
| 底部 tabBar | 不支持 | 支持 |
| 适用场景 | 简单功能、设置面板 | 复杂应用、内容型插件 |
| 电脑端体验 | 较好 | 一般（建议手机端使用） |
| 手机端体验 | 一般（空间小） | 很好（沉浸式） |

---

## 8. ctx API 参考 — 基础能力

以下 API **无需声明权限**，所有插件均可使用。

### ctx.pluginId

当前插件的 ID。

```javascript
const myId = ctx.pluginId; // "my-plugin"
```

### ctx.permissions

当前插件声明的权限列表（数组副本）。

### ctx.showToast(message, type)

显示一个 toast 提示。

| 参数 | 类型 | 说明 |
|---|---|---|
| `message` | string | 提示文字 |
| `type` | string | `"info"`（默认）、`"success"`、`"error"`、`"warning"` |

```javascript
ctx.showToast('保存成功', 'success');
ctx.showToast('网络错误', 'error');
```

### ctx.openModal(dom) / ctx.closeModal()

打开/关闭一个模态框。

- `openModal(dom)`：传入一个 DOM 元素，加载器会将其放入标准模态框中。

```javascript
window.myPluginOpen = function(ctx) {
    const content = document.createElement('div');
    content.innerHTML = '<h3>我的面板</h3><p>内容...</p>';
    ctx.openModal(content);
};
```

### ctx.openFloatingCard(config) / ctx.closeFloatingCard(cardId)

打开/关闭一个**悬浮可拖动卡片窗**。与全屏 modal 不同，悬浮卡片是一个小窗口，不会遮罩整个页面，可拖动、可同时打开多个，适合常驻型小工具（类似桌面小部件）。

**参数 `config`**：

| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| `id` | string | 自动生成 | 卡片唯一 ID，用于后续关闭/更新 |
| `title` | string | `'卡片'` | 标题栏文字 |
| `width` | number | `320` | 宽度（px），范围 160~800 |
| `height` | number | `220` | 高度（px），范围 80~900 |
| `x` | number | 居中 | 初始 left 坐标 |
| `y` | number | 居中 | 初始 top 坐标 |
| `content` | string\|Element\|Function | 无 | 卡片内容：HTML 字符串 / DOM 元素 / 渲染函数 `(bodyEl) => {}` |
| `closable` | boolean | `true` | 是否显示关闭按钮 |
| `resizable` | boolean | `false` | 是否可拖拽右下角调整大小 |
| `onClose` | Function | 无 | 卡片关闭时的回调 |

**返回值**：卡片 ID（string），可用于 `closeFloatingCard`。

```javascript
window.openMyPlugin = function(ctx) {
    // 打开一个悬浮卡片
    const cardId = ctx.openFloatingCard({
        id: 'my-tool-panel',
        title: '我的工具箱',
        width: 360,
        height: 280,
        x: 100,
        y: 100,
        resizable: true,
        content: function(body) {
            body.innerHTML = `
                <div style="padding:8px;">
                    <p>这是一个悬浮卡片，可以拖动标题栏移动位置。</p>
                    <button id="fcBtn">点我</button>
                </div>
            `;
            body.querySelector('#fcBtn').addEventListener('click', () => {
                ctx.showToast('你点击了卡片里的按钮！', 'success');
            });
        },
        onClose: function() {
            console.log('卡片已关闭');
        }
    });

    // 之后可以通过 ID 关闭
    // ctx.closeFloatingCard(cardId);
};
```

**特点**：
- 多个卡片可同时存在，点击会自动置顶
- 标题栏可拖动（pointer 事件，兼容触摸屏）
- 深色主题自动适配
- 插件卸载时自动关闭该插件的所有卡片

**与 openModal 的区别**：

| 特性 | openModal | openFloatingCard |
|---|---|---|
| 遮罩 | 全屏半透明遮罩 | 无遮罩 |
| 拖动 | 不可拖动 | 标题栏可拖动 |
| 多开 | 同时只能一个 | 可同时多个 |
| 调整大小 | 不可 | 可（`resizable: true`） |
| 适用场景 | 一次性操作/设置 | 常驻小工具/面板 |

### ctx.sendMessage(message)

向当前聊天室发送一条消息。

```javascript
ctx.sendMessage('这是插件发送的消息');
```

### ctx.getCurrentUser()

获取当前登录用户信息。

```javascript
const user = ctx.getCurrentUser();
console.log(user.username, user.avatar);
```

### ctx.on(eventName, callback) / ctx.emit(eventName, data)

插件事件总线（基于 `document.addEventListener`）。

```javascript
// 监听事件
ctx.on('user-joined', (data) => {
    ctx.showToast(data.username + ' 加入了聊天室', 'info');
});

// 发出事件
ctx.emit('my-event', { foo: 'bar' });
```

### ctx.openCommandPalette() / ctx.openPluginCenter()

打开命令面板 / 插件中心。

```javascript
window.myPluginOpen = function(ctx) {
    // 提供快捷键打开命令面板
    ctx.openCommandPalette();
};
```

---

## 7. ctx API 参考 — 受控能力（按权限）

以下 API 需要[声明对应权限](#5-权限系统)后才可用。

### 7.1 storage（本地存储）

**权限声明**：`"permissions": ["storage"]`

命名空间隔离的本地存储，每个插件的存储互不干扰。底层使用 `localStorage`，key 前缀为 `plugin:{插件id}:storage:`。

```javascript
// 写入（自动 JSON 序列化）
ctx.storage.set('userPrefs', { theme: 'dark', fontSize: 14 });

// 读取（自动 JSON 反序列化，不存在返回 null）
const prefs = ctx.storage.get('userPrefs'); // { theme: 'dark', fontSize: 14 }

// 删除单个
ctx.storage.remove('userPrefs');

// 获取所有 key（不含前缀）
const keys = ctx.storage.keys(); // ['userPrefs', 'history', ...]

// 清空该插件的所有存储
ctx.storage.clear();
```

| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
| `get(key)` | `key: string` | `any` 或 `null` | 读取存储值 |
| `set(key, value)` | `key: string`, `value: any` | — | 写入存储值 |
| `remove(key)` | `key: string` | — | 删除指定 key |
| `keys()` | — | `string[]` | 返回所有 key |
| `clear()` | — | — | 清空该插件所有存储 |

### 7.2 network（网络请求）

**权限声明**：`"permissions": ["network"]`

受控的 `fetch` 封装，默认 15 秒超时。

```javascript
window.myPluginOpen = async function(ctx) {
    try {
        const resp = await ctx.fetch('https://api.example.com/data');
        const data = await resp.json();
        ctx.showToast('获取成功: ' + data.result, 'success');
    } catch (err) {
        ctx.showToast('请求失败: ' + err.message, 'error');
    }
};
```

| 参数 | 类型 | 说明 |
|---|---|---|
| `url` | string | 请求 URL |
| `options` | object | 标准 fetch options，额外支持 `options.timeout`（毫秒，默认 15000） |

```javascript
// 自定义超时和请求头
const resp = await ctx.fetch('https://api.example.com/data', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ q: 'hello' }),
    timeout: 5000
});
```

### 7.3 notify（浏览器通知）

**权限声明**：`"permissions": ["notify"]`

发送浏览器桌面通知，首次使用会自动请求权限。

```javascript
window.myPluginOpen = async function(ctx) {
    const ok = await ctx.notify('任务完成', {
        body: '你的 3 个定时任务已全部完成',
        icon: '/path/to/icon.png',
        tag: 'task-complete',
        onclick: () => { ctx.showToast('点击了通知', 'info'); }
    });
    if (!ok) {
        ctx.showToast('通知权限未授予', 'warning');
    }
};
```

| 参数 | 类型 | 说明 |
|---|---|---|
| `title` | string | 通知标题 |
| `options.body` | string | 通知正文 |
| `options.icon` | string | 图标 URL |
| `options.tag` | string | 通知标签（同 tag 会替换） |
| `options.onclick` | function | 点击回调 |

返回 `Promise<boolean>`，`true` 表示发送成功。

### 7.4 clipboard（剪贴板）

**权限声明**：`"permissions": ["clipboard"]`

```javascript
window.myPluginOpen = async function(ctx) {
    // 写入剪贴板
    const ok = await ctx.clipboard.write('复制的文本内容');
    if (ok) ctx.showToast('已复制到剪贴板', 'success');

    // 读取剪贴板（需要用户手势，部分浏览器有限制）
    const text = await ctx.clipboard.read();
    ctx.showToast('剪贴板内容: ' + text, 'info');
};
```

| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
| `write(text)` | `text: string` | `Promise<boolean>` | 写入剪贴板，含降级方案 |
| `read()` | — | `Promise<string>` | 读取剪贴板 |

### 7.5 audio（音频播放）

**权限声明**：`"permissions": ["audio"]`

支持两种播放方式：URL 音频文件 和 WebAudio 蜂鸣合成。

```javascript
window.myPluginOpen = function(ctx) {
    // 方式一：播放音频文件
    ctx.playSound('https://example.com/beep.mp3');

    // 方式二：合成蜂鸣（无需音频文件）
    ctx.playSound({
        freq: 880,        // 频率 Hz，默认 440
        duration: 200,    // 持续毫秒，默认 200
        type: 'sine',     // 波形：'sine' | 'square' | 'sawtooth' | 'triangle'
        volume: 0.3       // 音量 0-1，默认 0.3
    });
};
```

| 参数 | 类型 | 说明 |
|---|---|---|
| `source` | string \| object | URL 字符串 或 `{ freq, duration, type, volume }` |

### 7.6 theme（主题）

**权限声明**：`"permissions": ["theme"]`

```javascript
window.myPluginOpen = function(ctx) {
    // 获取当前主题
    const theme = ctx.getTheme(); // 'light' 或 'dark'

    // 监听主题变更
    ctx.onThemeChange((newTheme) => {
        console.log('主题切换为:', newTheme);
        // 可在此更新插件 UI 配色
    });

    ctx.showToast('当前主题: ' + theme, 'info');
};
```

| 方法 | 返回值 | 说明 |
|---|---|---|
| `getTheme()` | `'light'` \| `'dark'` | 获取当前主题 |
| `onThemeChange(callback)` | — | 注册主题变更监听器，`callback(newTheme)` |

> 主题监听器在插件卸载/禁用时自动移除，无需手动取消。

### 7.7 interplugin（插件间通信）

**权限声明**：`"permissions": ["interplugin"]`

允许插件之间发送和接收消息，以及查询其他插件信息。

```javascript
// 插件 A：监听消息
window.pluginAOpen = function(ctx) {
    ctx.onPluginMessage((fromPluginId, message) => {
        console.log(`收到来自 ${fromPluginId} 的消息:`, message);
        ctx.showToast(`收到 ${fromPluginId} 的消息: ` + message.text, 'info');
    });
    ctx.showToast('插件 A 已开始监听', 'success');
};

// 插件 B：发送消息
window.pluginBOpen = function(ctx) {
    // 向插件 A 发送消息
    const delivered = ctx.sendToPlugin('plugin-a', { text: '你好，A！' });
    if (!delivered) {
        ctx.showToast('插件 A 未运行或未监听', 'warning');
    }
};
```

| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
| `sendToPlugin(targetId, message)` | `targetId: string`, `message: any` | `boolean` | 向目标插件发消息，`false` 表示目标未监听 |
| `onPluginMessage(callback)` | `callback(fromId, msg)` | — | 注册消息监听器 |
| `getPlugin(targetId)` | `targetId: string` | `object` \| `null` | 获取其他插件只读信息 |
| `listPlugins()` | — | `array` | 列出所有已安装插件 |

`getPlugin` 和 `listPlugins` 返回的对象结构：

```javascript
{
    id: 'plugin-a',
    name: '插件 A',
    version: '1.0.0',
    description: '...'
}
```

> 消息监听器在插件卸载/禁用时自动移除。

### 7.8 timers（托管定时器）

**权限声明**：`"permissions": ["timers"]`

通过 `ctx` 创建的定时器会被加载器跟踪，在插件卸载/禁用时**自动清理**，无需手动 `clearInterval`。

```javascript
window.myPluginOpen = function(ctx) {
    // 托管的 setInterval
    const intervalId = ctx.setInterval(() => {
        console.log('每秒执行一次');
        ctx.showToast('tick', 'info');
    }, 1000);

    // 托管的 setTimeout
    ctx.setTimeout(() => {
        ctx.showToast('5 秒后触发', 'success');
    }, 5000);

    // 手动清除
    // ctx.clearTimer(intervalId);

    // 插件被禁用/移除时，所有定时器会自动清理
};
```

| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
| `setTimeout(fn, delay)` | `fn: function`, `delay: number` | `number` | 延时执行，返回 timer id |
| `setInterval(fn, delay)` | `fn: function`, `delay: number` | `number` | 重复执行，返回 timer id |
| `clearTimer(id)` | `id: number` | — | 清除指定定时器 |

> **注意**：不要在插件中使用原生的 `setTimeout`/`setInterval` 来做需要随插件卸载而停止的任务，应使用 `ctx.setTimeout`/`ctx.setInterval` 确保自动清理。

### 7.9 dom（DOM 挂载点）

**权限声明**：`"permissions": ["dom"]`

获取各挂载点的 DOM 容器元素，插件可自由向其中插入自定义 UI。

```javascript
window.myPluginOpen = function(ctx) {
    // 获取悬浮按钮容器
    const floatingContainer = ctx.getMountContainer('floating');
    // 向容器添加自定义元素
    const customEl = document.createElement('div');
    customEl.textContent = '自定义内容';
    customEl.style.cssText = 'padding:8px;background:#eee;border-radius:8px;';
    floatingContainer.appendChild(customEl);
};
```

| 参数 | 返回值 | 说明 |
|---|---|---|
| `'toolbar'` | `HTMLElement` | 导航栏右侧容器 |
| `'sidebar'` | `HTMLElement` | 左侧边栏容器 |
| `'floating'` | `HTMLElement` | 右下角悬浮容器 |
| `'input-toolbar'` | `HTMLElement` | 聊天输入工具栏容器 |
| `'context-menu'` | `HTMLElement` | 消息右键菜单容器 |

> 手动插入的 DOM 元素不会自动清理，请在插件卸载时通过 hooks 或自行管理。

---

## 8. 动态入口 API

除了在 `.ctpark` 的 `ui` 中声明静态入口外，插件可以在运行时通过 `ctx` 动态注册、修改、删除入口。动态入口与静态入口共享同一套模型，自动出现在插件中心和命令面板中。

### 8.1 注册入口

```javascript
// 通用注册（指定 mount）
const entryId = ctx.addEntry({
    id: 'my-dynamic-entry',        // 可选，不提供则自动生成
    ownerPlugin: 'my-plugin',      // 可选，默认为当前插件
    mount: 'floating',             // 挂载位置
    label: '动态功能',
    icon: 'fas fa-star',
    action: function(ctx, msgCtx) {
        ctx.showToast('动态入口被点击', 'success');
    },
    category: '工具',
    order: 1
});

// 便捷方法（自动设置 mount）
ctx.registerFloatingButton({ id: 'float-btn', label: '悬浮', icon: 'fas fa-bolt', action: fn });
ctx.registerInputButton({ id: 'input-btn', label: '输入', icon: 'fas fa-magic', action: fn });
ctx.registerContextMenuItem({ id: 'ctx-item', label: '右键项', icon: 'fas fa-flag', action: fn });
ctx.registerPanelItem({ id: 'panel-item', label: '面板项', icon: 'fas fa-list', action: fn });
```

### 8.2 修改入口

```javascript
// 修改入口的 label/icon 等
ctx.updateEntry('my-dynamic-entry', {
    label: '新名称',
    icon: 'fas fa-new-icon'
});
```

> `updateEntry` 可以修改任意入口（包括其他插件的入口），DOM 会重新渲染。

### 8.3 删除入口

```javascript
ctx.removeEntry('my-dynamic-entry');
```

### 8.4 查询入口

```javascript
// 获取所有入口
const all = ctx.getEntries();

// 按条件过滤
const floatingEntries = ctx.getEntries({ mount: 'floating' });
const myEntries = ctx.getEntries({ ownerPlugin: 'my-plugin' });
const dynamicEntries = ctx.getEntries({ source: 'dynamic' });
```

### 8.5 入口配置结构

| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 入口唯一 ID。静态入口为 `static:{插件id}`，动态入口为 `dynamic:{id}` 或 `dynamic:auto:{n}`。 |
| `ownerPlugin` | string | 归属插件 ID |
| `mount` | string | 挂载位置 |
| `label` | string | 显示标签 |
| `icon` | string | Font Awesome 图标类名 |
| `action` | function \| string | 点击处理函数或 `window` 函数名 |
| `category` | string | 分类 |
| `order` | number | 排序权重 |
| `source` | string | `'static'` 或 `'dynamic'` |

### 8.6 跨插件操作

动态入口 API 支持跨插件操作。通过指定 `ownerPlugin`，一个插件可以为另一个插件注册入口：

```javascript
// 插件 B 为插件 A 添加一个入口
ctx.addEntry({
    ownerPlugin: 'plugin-a',
    mount: 'floating',
    label: '由 B 添加',
    icon: 'fas fa-link',
    action: function(ctx) { ctx.showToast('跨插件入口', 'info'); }
});

// 修改插件 A 的入口
ctx.updateEntry('static:plugin-a', { label: '被 B 改名了' });
```

---

## 9. 悬浮按钮拖拽与位置控制

所有 `mount: "floating"` 的按钮（静态和动态）**自动支持拖拽**：

- 按住按钮拖动可移动到屏幕任意位置。
- 位置按入口 ID 持久化到 `localStorage`，刷新后自动恢复。
- 拖拽与点击智能区分：移动超过 4px 才视为拖拽。
- 边界限制：至少保留一半按钮在视口内。
- 窗口尺寸变化时自动校正越界位置。

插件无需编写任何拖拽代码，加载器自动处理。

### 编程式位置控制

插件可通过 ctx API 编程式控制悬浮按钮位置：

```javascript
window.myPluginOpen = function(ctx) {
    const entryId = 'static:my-plugin';

    // 设置位置
    ctx.setFloatingPosition(entryId, 100, 200);

    // 读取位置（未自定义时返回 null）
    const pos = ctx.getFloatingPosition(entryId);
    console.log(pos); // { x: 100, y: 200 } 或 null

    // 重置到默认位置
    ctx.resetFloatingPosition(entryId);
};
```

| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
| `setFloatingPosition(entryId, x, y)` | `entryId: string`, `x: number`, `y: number` | `boolean` | 设置并持久化位置 |
| `getFloatingPosition(entryId)` | `entryId: string` | `object \| null` | 读取位置 `{x, y}` |
| `resetFloatingPosition(entryId)` | `entryId: string` | — | 重置到默认位置 |

---

## 10. 插件生命周期

### 生命周期阶段

```
安装（上传 .ctpark）
    │
    ▼
applyPlugin（启用）
    ├── 注入 CSS（<style id="plugin-css-{id}">）
    ├── 注入 JS（<script id="plugin-js-{id}">）
    ├── 挂载 UI（创建按钮）
    ├── 注册静态入口
    └── 注册 hooks
    │
    ▼  运行中...
    │
    ├── 禁用（disablePlugin）
    │     ├── 移除 CSS/JS DOM
    │     ├── 移除所有入口 DOM
    │     ├── 清理定时器/监听器/消息处理器
    │     └── 保留 pluginData（可重新启用）
    │
    ├── 重新启用（applyPlugin）
    │     └── 重新注入 CSS/JS，重新挂载 UI
    │
    └── 移除（removePlugin）
          ├── 移除 CSS/JS DOM
          ├── 移除所有入口 DOM
          ├── 清理定时器/监听器/消息处理器
          ├── 删除 pluginData
          └── 删除 localStorage 中的插件数据
```

### hooks（生命周期钩子）

在 `.ctpark` 的 `hooks` 字段中注册钩子函数：

```json
{
  "hooks": {
    "onLoad": "myPluginOnLoad",
    "onUnload": "myPluginOnUnload"
  }
}
```

```javascript
// js 字段
window.myPluginOnLoad = function() {
    console.log('插件已加载');
};
window.myPluginOnUnload = function() {
    console.log('插件已卸载');
};
```

> hooks 的 handler 值可以是函数对象或 `window` 函数名字符串。

### 自动资源清理

以下资源在插件禁用/移除时会**自动清理**，无需手动处理：

- 通过 `ctx.setTimeout` / `ctx.setInterval` 创建的定时器
- 通过 `ctx.onThemeChange` 注册的主题监听器
- 通过 `ctx.onPluginMessage` 注册的消息监听器
- 通过 `ctx.addEntry` 等注册的动态入口 DOM
- 静态入口的按钮 DOM
- 注入的 CSS/JS 标签

**需要手动清理的资源**：
- 通过 `ctx.on(eventName, callback)` 注册的事件监听（基于 `document.addEventListener`）
- 通过 `ctx.getMountContainer` 手动插入的 DOM 元素
- 直接使用原生 `setTimeout`/`setInterval` 创建的定时器
- 直接操作 `window`/`document` 添加的全局变量和监听器

对于需要手动清理的资源，建议在 `hooks.onUnload` 中处理。

---

## 11. 完整示例

### 示例 1：待办事项插件（综合示例）

```json
{
  "id": "todo-list",
  "name": "待办事项",
  "version": "1.0.0",
  "description": "右下角悬浮的待办事项管理器，支持本地持久化",
  "author": "小黑窝",
  "permissions": ["storage", "notify", "timers"],
  "ui": {
    "entry": "button",
    "mount": "floating",
    "label": "待办",
    "icon": "fas fa-list-check",
    "action": "todoListOpen"
  },
  "css": ".todo-modal-body{padding:12px}.todo-input-row{display:flex;gap:8px;margin-bottom:12px}.todo-input-row input{flex:1;padding:8px;border:1px solid var(--border-color);border-radius:8px;background:var(--background-color);color:var(--text-color)}.todo-add-btn{padding:8px 16px;border:none;border-radius:8px;background:var(--primary-color);color:white;cursor:pointer}.todo-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:8px;background:rgba(var(--primary-rgb),.05);margin-bottom:6px}.todo-item.done{text-decoration:line-through;opacity:.5}.todo-text{flex:1}.todo-del{background:transparent;border:none;color:#f44336;cursor:pointer}",
  "js": "window.todoListOpen = function(ctx) { const content = document.createElement('div'); content.className = 'todo-modal-body'; content.innerHTML = '<div class=\"todo-input-row\"><input type=\"text\" id=\"todoInput\" placeholder=\"输入待办事项...\"><button class=\"todo-add-btn\" id=\"todoAddBtn\">添加</button></div><div id=\"todoList\"></div>'; ctx.openModal(content); function render() { const todos = ctx.storage.get('todos') || []; const listEl = content.querySelector('#todoList'); listEl.innerHTML = todos.map((t, i) => '<div class=\"todo-item' + (t.done ? ' done' : '') + '\"><span class=\"todo-text\" data-idx=\"' + i + '\">' + t.text + '</span><button class=\"todo-del\" data-idx=\"' + i + '\">删除</button></div>').join('') || '<div style=\"text-align:center;color:var(--text-muted);padding:16px\">暂无待办</div>'; } content.querySelector('#todoAddBtn').addEventListener('click', () => { const input = content.querySelector('#todoInput'); const text = input.value.trim(); if (!text) return; const todos = ctx.storage.get('todos') || []; todos.push({ text, done: false }); ctx.storage.set('todos', todos); input.value = ''; render(); ctx.showToast('已添加', 'success'); }); content.querySelector('#todoList').addEventListener('click', (e) => { const idx = parseInt(e.target.dataset.idx, 10); if (isNaN(idx)) return; const todos = ctx.storage.get('todos') || []; if (e.target.classList.contains('todo-del')) { todos.splice(idx, 1); ctx.showToast('已删除', 'info'); } else if (e.target.classList.contains('todo-text')) { todos[idx].done = !todos[idx].done; if (todos[idx].done) { ctx.playSound && ctx.playSound({ freq: 660, duration: 100 }); } } ctx.storage.set('todos', todos); render(); }); render(); };"
}
```

### 示例 2：消息翻译插件（context-menu + network）

```json
{
  "id": "msg-translate",
  "name": "消息翻译",
  "version": "1.0.0",
  "description": "右键消息即可翻译为英文",
  "permissions": ["network", "clipboard"],
  "ui": {
    "entry": "button",
    "mount": "context-menu",
    "label": "翻译为英文",
    "icon": "fas fa-language",
    "action": "msgTranslateAction"
  },
  "js": "window.msgTranslateAction = async function(ctx, msgCtx) { if (!msgCtx || !msgCtx.message) { ctx.showToast('无法获取消息内容', 'error'); return; } const text = msgCtx.message.content || msgCtx.message.text || ''; if (!text) { ctx.showToast('消息无文本内容', 'warning'); return; } ctx.showToast('翻译中...', 'info'); try { const resp = await ctx.fetch('https://api.example.com/translate?q=' + encodeURIComponent(text)); const data = await resp.json(); const translated = data.translated || '翻译失败'; await ctx.clipboard.write(translated); ctx.showToast('翻译结果已复制: ' + translated.slice(0, 20), 'success'); } catch (err) { ctx.showToast('翻译失败: ' + err.message, 'error'); } };"
}
```

### 示例 3：主题感知时钟插件（theme + timers + floating）

```json
{
  "id": "theme-clock",
  "name": "主题时钟",
  "version": "1.0.0",
  "description": "跟随主题切换配色的悬浮时钟",
  "permissions": ["theme", "timers"],
  "ui": {
    "entry": "button",
    "mount": "floating",
    "label": "时钟",
    "icon": "fas fa-clock",
    "action": "themeClockOpen"
  },
  "css": ".tc-display{font-size:20px;font-weight:700;font-family:monospace;color:var(--primary-color)}",
  "js": "window.themeClockOpen = function(ctx) { const content = document.createElement('div'); content.className = 'tc-display'; content.style.cssText = 'padding:20px;text-align:center;'; ctx.openModal(content); function update() { const now = new Date(); content.textContent = now.toLocaleTimeString(); } update(); const id = ctx.setInterval(update, 1000); const theme = ctx.getTheme(); ctx.showToast('当前主题: ' + theme, 'info'); ctx.onThemeChange((newTheme) => { ctx.showToast('主题切换为 ' + newTheme, 'info'); }); };"
}
```

---

## 12. 最佳实践

### 12.1 权限最小化

只声明插件实际需要的权限，不要贪多：

```json
// 好 — 只声明需要的
"permissions": ["storage"]

// 不好 — 声明所有权限但只用一个
"permissions": ["storage", "network", "notify", "clipboard", "audio", "theme", "interplugin", "timers", "dom"]
```

### 12.2 使用托管定时器

需要随插件卸载而停止的定时任务，务必使用 `ctx.setTimeout`/`ctx.setInterval`：

```javascript
// 好
const id = ctx.setInterval(() => { /* ... */ }, 1000);

// 不好 — 插件卸载后仍在运行
setInterval(() => { /* ... */ }, 1000);
```

### 12.3 action 函数命名

用插件 id 作为前缀，避免与其他插件冲突：

```javascript
// 好
window.todoListOpen = function(ctx) { ... };

// 不好 — 太通用，容易冲突
window.open = function(ctx) { ... };
```

### 12.4 错误处理

对异步操作（fetch、clipboard 等）进行 try-catch：

```javascript
window.myPluginOpen = async function(ctx) {
    try {
        const resp = await ctx.fetch(url);
        // ...
    } catch (err) {
        ctx.showToast('操作失败: ' + err.message, 'error');
    }
};
```

### 12.5 CSS 命名空间

插件 CSS 类名用插件 id 前缀，避免样式冲突：

```css
/* 好 */
.todo-item { ... }
.todo-input { ... }

/* 不好 — 太通用 */
.item { ... }
.input { ... }
```

### 12.6 存储清理

如果插件存储了大量数据，建议在卸载时清理（通过 hooks 或提示用户）：

```javascript
window.myPluginOnUnload = function() {
    // 可选：清理本插件存储
    // 注意：ctx 在 onUnload 中可能不可用，可通过 window.PluginManager 访问
};
```

### 12.7 响应式适配

插件 UI 应考虑不同屏幕尺寸。加载器已提供移动端基础适配，插件自定义 UI 应使用相对单位或 `max-width`。

### 12.8 移动端触控适配

加载器提供了完整的移动端触控支持，插件开发者可以通过以下方式确保在手机端有良好的用户体验。

#### 12.8.1 检测移动端

通过 `ctx.isMobile` 判断当前是否为移动设备：

```javascript
window.myPluginOpen = function(ctx) {
    if (ctx.isMobile) {
        ctx.showToast('欢迎使用移动版！', 'info');
    }
    // 根据设备类型调整 UI
    const container = document.createElement('div');
    container.style.padding = ctx.isMobile ? '12px' : '20px';
};
```

#### 12.8.2 触控支持 API

加载器提供了两个辅助方法用于触控适配：

```javascript
window.myPluginOpen = function(ctx) {
    const content = document.createElement('div');
    content.innerHTML = '<button id="my-btn">点击我</button>';
    
    const btn = content.querySelector('#my-btn');
    
    ctx.ensureTouchSupport(btn);
    
    ctx.attachTouchClick(btn, (e) => {
        ctx.showToast('触摸点击！', 'success');
    });
    
    ctx.openModal(content);
};
```

**`ctx.ensureTouchSupport(element)`**
- 为元素添加触摸支持（设置 `touch-action: manipulation`）
- 仅在移动设备上生效

**`ctx.attachTouchClick(element, handler, options)`**
- 为元素绑定触摸点击事件
- 处理触摸开始和结束，区分点击和滑动
- 返回一个解绑函数

#### 12.8.3 触控事件最佳实践

1. **使用 Pointer Events**：优先使用 `pointerdown`、`pointermove`、`pointerup` 事件，它们统一了鼠标和触摸事件

2. **避免重复绑定**：不要同时绑定 `click` 和 `touchstart`，可能导致双击或延迟问题

3. **触摸目标尺寸**：移动端按钮建议最小尺寸为 44px × 44px

4. **防止误触**：对可拖拽元素，移动超过一定距离（如 4px）后才视为拖拽

5. **CSS 触摸反馈**：使用 `:active` 伪类提供视觉反馈

```css
.my-btn {
    min-width: 44px;
    min-height: 44px;
    transition: transform 0.1s;
}

.my-btn:active {
    transform: scale(0.95);
}
```

#### 12.8.4 移动端 UI 适配建议

| 场景 | 建议 |
|---|---|
| 悬浮按钮 | 加载器自动放大到 56px × 56px |
| 工具栏按钮 | 加载器自动放大到 44px × 44px |
| 输入栏按钮 | 加载器自动放大到 40px × 40px |
| 悬浮卡片 | 支持触摸拖拽标题栏移动 |
| 插件卡片 | 增加内边距和触摸目标 |

#### 12.8.5 原生触摸事件处理

如果需要更精细的触摸控制，可以直接使用原生触摸事件：

```javascript
window.myPluginOpen = function(ctx) {
    const canvas = document.createElement('canvas');
    let touchStartX = 0;
    
    canvas.addEventListener('touchstart', (e) => {
        touchStartX = e.touches[0].clientX;
        e.preventDefault();
    }, { passive: false });
    
    canvas.addEventListener('touchmove', (e) => {
        const dx = e.touches[0].clientX - touchStartX;
        console.log('水平移动:', dx);
    });
    
    ctx.openModal(canvas);
};
```

> **注意**：使用 `preventDefault()` 时要谨慎，可能会阻止页面滚动。建议使用 `passive: false` 选项。

---

## 13. 调试技巧

### 13.1 查看插件状态

在浏览器控制台中：

```javascript
// 查看所有已安装插件
PluginManager.plugins

// 查看所有入口
PluginManager.entries

// 查看插件声明的权限
PluginManager.plugins.get('my-plugin').permissions

// 手动触发 action
const pd = PluginManager.plugins.get('my-plugin');
const ctx = PluginManager.createContext(pd);
window.myAction(ctx);
```

### 13.2 检查权限是否生效

```javascript
const pd = PluginManager.plugins.get('my-plugin');
const ctx = PluginManager.createContext(pd);
console.log('storage:', typeof ctx.storage);        // 'object' 或 'undefined'
console.log('fetch:', typeof ctx.fetch);            // 'function' 或 'undefined'
console.log('permissions:', ctx.permissions);       // ['storage', ...]
```

### 13.3 模拟插件加载

```javascript
// 从文件加载
fetch('plugins/my-plugin.ctpark')
    .then(r => r.text())
    .then(text => {
        const pd = JSON.parse(text);
        PluginManager.plugins.set(pd.id, pd);
        PluginManager.applyPlugin(pd);
    });
```

### 13.4 查看存储数据

```javascript
// 查看插件的存储数据
Object.keys(localStorage)
    .filter(k => k.startsWith('plugin:my-plugin:storage:'))
    .forEach(k => console.log(k, localStorage.getItem(k)));
```

### 13.5 测试拖拽位置

```javascript
// 设置位置
PluginManager.setFloatingPositionExternal('static:my-plugin', 100, 100);

// 读取位置
PluginManager.getFloatingPositionExternal('static:my-plugin');

// 重置
PluginManager.resetFloatingPosition('static:my-plugin');
```

### 13.6 移动端调试

#### 模拟移动设备

使用 Chrome DevTools 的设备模拟功能：

1. 打开 Chrome DevTools（F12）
2. 点击左上角的设备切换图标（Toggle device toolbar）
3. 选择一个移动设备（如 iPhone 14）
4. 刷新页面，`ctx.isMobile` 会返回 `true`

#### 检查触摸事件

```javascript
// 查看当前是否为移动设备
console.log('isMobile:', PluginManager.isMobile);

// 检查元素是否有触摸支持
const btn = document.querySelector('.plugin-btn');
console.log('touch-action:', btn.style.touchAction);
```

#### 测试触摸点击

```javascript
// 模拟触摸事件
const btn = document.querySelector('.plugin-btn');
const touchStart = new TouchEvent('touchstart', {
    touches: [{ clientX: 100, clientY: 200 }]
});
const touchEnd = new TouchEvent('touchend', {
    changedTouches: [{ clientX: 100, clientY: 200 }]
});
btn.dispatchEvent(touchStart);
btn.dispatchEvent(touchEnd);
```

---

## 14. API 速查表

### 基础能力（无需权限）

| API | 签名 | 说明 |
|---|---|---|
| `ctx.pluginId` | `string` | 当前插件 ID |
| `ctx.permissions` | `string[]` | 声明的权限列表 |
| `ctx.showToast` | `(msg, type?) => void` | 显示提示 |
| `ctx.openModal` | `(dom) => void` | 打开模态框 |
| `ctx.closeModal` | `() => void` | 关闭模态框 |
| `ctx.sendMessage` | `(msg) => void` | 发送聊天消息 |
| `ctx.getCurrentUser` | `() => object` | 获取当前用户 |
| `ctx.on` | `(event, cb) => void` | 监听事件 |
| `ctx.emit` | `(event, data) => void` | 发出事件 |
| `ctx.addEntry` | `(config) => string` | 注册动态入口 |
| `ctx.removeEntry` | `(id) => boolean` | 删除入口 |
| `ctx.updateEntry` | `(id, patch) => boolean` | 修改入口 |
| `ctx.getEntries` | `(filter?) => array` | 查询入口 |
| `ctx.registerFloatingButton` | `(config) => string` | 注册悬浮按钮 |
| `ctx.registerInputButton` | `(config) => string` | 注册输入栏按钮 |
| `ctx.registerContextMenuItem` | `(config) => string` | 注册右键菜单项 |
| `ctx.registerPanelItem` | `(config) => string` | 注册面板入口 |
| `ctx.openCommandPalette` | `() => void` | 打开命令面板 |
| `ctx.openPluginCenter` | `() => void` | 打开插件中心 |
| `ctx.openFloatingCard` | `(config) => string` | 打开悬浮可拖动卡片窗，返回卡片 ID |
| `ctx.closeFloatingCard` | `(cardId) => boolean` | 关闭指定悬浮卡片 |
| `ctx.setFloatingPosition` | `(id, x, y) => boolean` | 设置悬浮按钮位置 |
| `ctx.getFloatingPosition` | `(id) => object\|null` | 读取悬浮按钮位置 |
| `ctx.resetFloatingPosition` | `(id) => void` | 重置悬浮按钮位置 |
| `ctx.isMobile` | `boolean` | 是否为移动设备 |
| `ctx.ensureTouchSupport` | `(element) => void` | 为元素添加触摸支持（仅移动设备生效） |
| `ctx.attachTouchClick` | `(element, handler, options) => function` | 绑定触摸点击事件，返回解绑函数 |

### 受控能力（需声明权限）

| 权限 | API | 说明 |
|---|---|---|
| `storage` | `ctx.storage.get/set/remove/keys/clear` | 本地存储 |
| `network` | `ctx.fetch(url, options)` | 网络请求 |
| `notify` | `ctx.notify(title, options)` | 浏览器通知 |
| `clipboard` | `ctx.clipboard.write/read` | 剪贴板 |
| `audio` | `ctx.playSound(source)` | 音频播放 |
| `theme` | `ctx.getTheme()` / `ctx.onThemeChange(cb)` | 主题 |
| `interplugin` | `ctx.sendToPlugin` / `ctx.onPluginMessage` / `ctx.getPlugin` / `ctx.listPlugins` | 插件间通信 |
| `timers` | `ctx.setTimeout` / `ctx.setInterval` / `ctx.clearTimer` | 托管定时器 |
| `dom` | `ctx.getMountContainer(mount)` | DOM 挂载点 |

---

## 15. HTML 转 CTPARK 工具

为了降低插件开发门槛，我们提供了图形化转换工具，可以将普通的 HTML 网页快速转换为 `.ctpark` 插件格式。工具共有两个实现版本：**C# 版（推荐）** 与 **Python 版（备选）**，界面与功能完全一致。

### 15.1 工具位置

```
tools/html2ctpark.exe          ← C# 版主程序（.NET 8，推荐）
tools/html2ctpark.dll
tools/html2ctpark.runtimeconfig.json
tools/html2ctpark-net48.exe    ← C# 版免安装程序（.NET Framework 4.x，可选）
tools/html2ctpark.py           ← Python 版（备选，界面相同）
tools/启动转换工具.bat           ← 一键启动（优先 C# 版，找不到则回退 Python）
tools/build-net48.bat          ← 可选：一键编译免安装版（见 15.2.2）
tools/html2ctpark.cs           ← C# 版源码（一套代码，双目标编译）
```

### 15.2 运行方式

**方式一：双击 `启动转换工具.bat`（推荐）**

脚本会自动检测：优先启动 C# 版，若不存在则回退到 Python 版。

**方式二：C# 版（.NET 8 桌面运行时）**

环境要求：Windows 10/11 + [.NET 8 桌面运行时](https://dotnet.microsoft.com/download/dotnet/8.0)（SDK 或 Runtime 均可）。

```bash
start tools\html2ctpark.exe
```

**方式三：Python 版**

环境要求：Python 3.7+（自带 tkinter，Windows 默认安装）。

```bash
python tools/html2ctpark.py
```

### 15.2.1 杀毒软件误报处理

C# 版因包含「剪贴板、网络请求、DOM 操作」等能力描述，部分杀毒软件（如 360 安全卫士）的启发式引擎可能将其误判为威胁并隔离。如遇到此情况：

1. 在杀毒软件的「隔离区 / 恢复区」中找回并恢复 `html2ctpark.exe`；
2. 将 `tools` 目录加入杀毒软件白名单 / 信任区；
3. 重新运行 `启动转换工具.bat`。

### 15.2.2 免安装版（.NET Framework 4.x，可选）

Windows 自带 .NET Framework 4.x 运行时，无需安装任何东西。如果不想安装 .NET 8，或杀毒软件误报无法解决，可用 `tools` 目录下的 `build-net48.bat` 一键编译免安装版：

```bash
build-net48.bat
```

编译成功后生成 `html2ctpark-net48.exe`，双击即可运行（此版本同样需要杀毒软件放行）。`tools` 目录已附赠编译好的 `html2ctpark-net48.exe`，可直接使用、无需自行编译。源码在 `tools/html2ctpark.cs`，同时支持 .NET Framework 与 .NET 8 两种编译目标。如需重新构建 .NET 8 版：

```bash
dotnet publish tools/html2ctpark-net8.csproj -c Release -o tools
```

### 15.3 功能介绍

工具界面分为左右两栏，左侧可滚动：

| 区域 | 功能 |
|---|---|
| **左侧 - 基本信息** | 编辑插件 ID、名称、版本、作者、描述，每项都有汉语说明 |
| **左侧 - UI 入口配置** | 设置入口类型、挂载位置等，每个选项切换时显示对应的汉语解释 |
| **左侧 - 权限声明** | 勾选插件需要的权限，每项附有用途说明 |
| **右侧 - 代码编辑** | 三个页签：JavaScript（默认）、CSS、HTML |

**特色功能**：

- **👁 可视化预览**：点击工具栏「可视化预览」按钮，会弹出模拟的聊天室界面，用黄色高亮标出你的按钮会出现在哪个位置，悬浮球还能拖动体验
- **❓ 概念帮助**：点击「概念帮助」按钮，查看完整的插件结构说明和挂载位置图解
- **🔄 同步函数名**：一键将「动作函数名」同步到 JS 代码中，避免函数名不匹配导致点击无反应
- **导出前检查**：导出时自动检查 JS 中是否定义了 `window.函数名`，不匹配会警告

### 15.4 使用流程

#### 方式一：从 HTML 文件导入

1. 点击顶部「📂 导入 HTML」按钮，选择你的 `.html` 文件
2. 工具会自动提取：
   - `<style>` 标签内的 CSS → 填入 CSS 页签
   - 内联 `<script>` 中的 JS → 填入 JavaScript 页签
   - `<body>` 中的 HTML 结构 → 填入 HTML(Body) 页签
   - 文件名自动作为插件 ID 和名称
3. 在左侧调整基本信息和 UI 配置（切换选项可看到汉语解释）
4. 点击「👁 可视化预览」确认按钮位置
5. 点击「💾 导出 .ctpark」保存插件文件

#### 方式二：从现有插件导入修改

1. 点击「📋 导入已有 .ctpark」按钮，选择现有插件
2. 所有配置和代码自动加载到界面
3. 修改后重新导出

#### 方式三：从零开始创建

1. 直接在左侧填写基本信息
2. 选择挂载位置（切换时下方会显示汉语说明）
3. 在右侧 JS 页签编写代码（默认模板已包含 `openFloatingCard` 示例）
4. 点击「🔄 同步函数名到 JS 模板」确保函数名匹配
5. 导出即可

### 15.5 UI 配置项说明

| 配置项 | 可选值/说明 |
|---|---|
| **入口类型 (entry)** | `button`（按钮）或 `panel`（面板） |
| **挂载位置 (mount)** | `toolbar`（导航栏右侧）、`sidebar`（左侧边栏）、`floating`（右下角悬浮）、`panel`（插件中心）、`context-menu`（右键菜单）、`input-toolbar`（消息输入栏） |
| **显示文字 (label)** | 按钮上显示的文字 |
| **图标 (icon)** | Font Awesome 图标 class，如 `fas fa-clock`、`fas fa-puzzle-piece` |
| **动作函数 (action)** | 点击按钮时调用的全局函数名，必须在 JS 中定义为 `window.xxx = function(ctx) { ... }` |

### 15.6 权限列表

| 权限 | 用途 |
|---|---|
| `storage` | 本地存储（读写 localStorage 隔离区） |
| `clipboard` | 读写剪贴板 |
| `audio` | 播放音频 |
| `theme` | 获取当前主题、监听主题变化 |
| `interplugin` | 与其他插件通信 |
| `timers` | 使用托管定时器（自动清理） |
| `dom` | 获取挂载容器 DOM |

### 15.7 注意事项

1. **动作函数必须挂载到 window**：JS 中的入口函数需要定义为 `window.函数名 = function(ctx) { ... }`，与 UI 配置的 `action` 字段一致
2. **CSS 作用域**：建议所有 CSS 类名加上插件前缀（如 `.my-plugin-xxx`），避免与主站样式冲突
3. **HTML 用途**：HTML(Body) 页签的内容仅供参考，实际插件的 UI 通常在 JS 中动态创建模态框
4. **外部 script**：带 `src` 的 `<script>` 标签内容不会被提取，请手动将代码复制到 JS 页签

### 15.8 验证插件

导出 `.ctpark` 文件后，按以下步骤验证：

1. 打开聊天室 → 左侧「插件中心」
2. 点击底部「上传插件」按钮，选择导出的 `.ctpark` 文件
3. 安装后查看插件中心列表，点击启用
4. 测试按钮点击、功能是否正常
5. 按 F12 打开控制台，检查是否有报错

---

> 如有疑问或需要更多示例，请参考 `团长MC公布的例示插件` 、或在QQ群交流。

© 2026 B站 团长MC--MekonCubeSTUDIO
本文档一切版权归墨盒立方工作室所有，禁止复制、修改、分发。