TutuHai

图图嗨小程序开放文档

SDK 接入 · 能力 API · 云数据 · 授权 · 发布

图图嗨小程序是运行在图图嗨内的轻量应用。开发者只上传前端代码包,由平台托管;业务能力通过 window.tt SDK 与图图嗨通信,无需自建后端(业务数据走图图嗨云数据)。SDK 接入 · 能力 API · 云数据 · 授权 · 发布,一页看全,示例可复制即用。

去控制台建小程序 7 大类 · 31 个主题 · 点击标题展开
目录 · 31 个主题

入门

数据能力

会话与多人

文件与云盘

多程序联动

界面 · 窗口 · 宿主

参考

入门

简介

图图嗨小程序是运行在图图嗨内的轻量应用。开发者只上传前端代码包,由平台托管;业务能力通过 window.tt SDK 与图图嗨通信,无需自建后端(业务数据走图图嗨云数据)。

隔离与安全:小程序跑在独立源的沙箱 iframe,宿主登录态绝不进入小程序;每次调用由宿主签发短命受限令牌,后端按能力(scope)二次校验。

快速开始
  1. 小程序控制台(/applets)「新建」一个小程序(名称 + 唯一 slug)。
  2. 写一个单文件 HTML(引入 SDK,用 window.tt.* 调能力)。
  3. 新建版本 → 填申请能力 → 上传代码包(单文件 HTML)。
  4. 提交审核 → 管理员通过 → 一键上架为线上版本。
  5. 用户在「发现」搜索/打开,或你分享卡片 / 复制链接直达。
打包规范

小程序支持两种上传形态:①单文件 HTML 代码包(自包含,入口 = 根,最简);②真实框架构建产物 zip(npm run builddist/,含 index.html + assets 多文件,见「框架构建产物」)。平台在上传前均做规范校验与优化。

包结构(编译产物)

your-applet/            # 开发目录(任意结构:src、组件、资源…)
├─ src/ …               # 你的源码(可 React / Vue / Svelte / 原生)
└─ dist/index.html      # ★编译产物:单文件 HTML(← 上传这个)
                        #   内联 CSS/JS,或引用白名单 CDN;自包含、无需服务器

代码包必须满足(上传时自动校验):

  • 入口:单个 HTML 文件,含 <!doctype html> 与根 <html>
  • 移动端适配:必须含 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  • SDK:引入 <script src="/applet-sdk.js">(平台自动改写为宿主绝对地址)。
  • 自包含:CSS/JS 内联;如需外部脚本,仅允许平台 SDK + 知名框架 CDN(unpkg / jsdelivr / cdnjs / esm.sh),禁止任意远端脚本(安全)。图片等媒体走 tt.uploadImage 或 CDN。
  • 体积:单文件 HTML ≤ 1MB;多文件 zip 默认总量 ≤ 8MB、单文件 ≤ 1MB、文件数 ≤ 100(这三个数后台可调,以本站实际为准——撞上了可请运营方调高,见下文「契约体检」);上传的图片 ≤ 4MB。
  • 数据:无自有后端,业务数据走 tt.cloud 云数据 / tt.*storage

📱🖥 移动端 / 桌面端通用(一份代码两端跑)

同一个代码包在图图嗨内以隔离 iframe 运行,宿主在移动端(全屏)桌面端(面板 / 可全屏)均能承载。请用响应式布局写一份通用代码:①viewport-fit=cover + 安全区 env(safe-area-inset-*);②弹层用底部抽屉(移动)↔ 居中(桌面,@media(min-width:480px));③触控目标 ≥ 44px;④跟随宿主暗黑/浅色(tt.onThemeChange / [data-theme]);⑤纯 DOM、不写死宽度。这样无论手机还是电脑,体验一致。

框架构建产物(dist.zip)

除了单文件 HTML,你也可以直接上传真实框架的构建产物 —— 用 React / Vue / Svelte / Angular / Solid / Astro / Next(静态导出)/ 原生……任意工具链 npm run build,把 dist/(含 index.html + assets/*.js/css + 字体/图片)打包成 zip 上传即可托管运行。

平台框架无关:它只认一份「通用静态包契约」—— 入口 index.html + 相对资源引用 + SDK。任何能产出满足该契约的静态 dist 的框架都支持,下方脚手架只是精选便捷模板,不是支持边界。

zip 结构(build 产物,zip 根 = 包根)

myapp.zip
├─ index.html          # ★入口(zip 根)
├─ manifest.json       # 声明 slug/name/version/scopes(见下)
└─ assets/
   ├─ index-*.js       # 构建后的 JS(相对引用)
   ├─ index-*.css
   └─ font/img…

三步适配(任意框架通用):

  1. 设相对 base(推荐,最稳妥) —— 让产物用相对路径引资源(./assets/x.js 而非根绝对 /assets/x.js),托管在 /<slug>/ 下万无一失。(默认 base 也能跑:平台会自动重写 HTML/CSS 的静态根绝对引用,并用 Referer 兜底运行时生成的根绝对资源——如代码分包 CSS 的 preload;但严格 Referrer-Policy / 离线预取等极端场景 Referer 可能缺失致兜底失效,故相对 base 最保险。)
  2. 放 manifest —— 把 manifest.json 放到静态目录(如 Vite/SvelteKit 的 static/、多数框架的 public/),build 后自动落到 dist 根;或省略 manifest,在 index.html<meta name="tt:slug" content="…">(及 tt:name / tt:version / tt:scopes)兜底。
  3. 引 SDK —— 两种方式任选:①npm 安装(推荐,真实脚手架首选) npm i @tutuhai/applet-sdkimport { tt } from '@tutuhai/applet-sdk',构建时打进产物、带 TypeScript 类型、无需改 index.html;②或在 index.html<script src="/applet-sdk.js">(平台自动改写为宿主绝对地址)用全局 window.tt

📦 npm SDK(React / Vue / Svelte 官方脚手架实测)

npm create vite@latest -- --template react-ts | vue-ts | svelte-ts 建工程,装同一个 @tutuhai/applet-sdk,import 使用即可 —— 真实源码多文件、npm run build 官方编译产出多 chunk + 一个入口 index.html,打包成 zip 上传。

# 1) 官方脚手架建工程
npm create vite@latest my-applet -- --template react-ts   # 或 vue-ts / svelte-ts

# 2) 装 SDK(三框架同一个包)
npm i @tutuhai/applet-sdk

# 3) 源码里 import 使用(带类型)
#    src/App.tsx / App.vue / App.svelte
import { tt } from '@tutuhai/applet-sdk';
tt.ready((ctx) => {
  tt.getProfile().then((me) => console.log('hi', me?.nickname));
});
await tt.cloud.add('notes', { text: '你好' });   // 云数据,无需自有后端

# 4) vite.config:相对 base;public/manifest.json 声明 slug/name/scopes
#    export default { base: './', plugins: [react()] }

# 5) 官方编译 → 打包 dist → 上传
npm run build && cd dist && zip -r ../my-applet.zip .

仓内可运行范例:applets/frameworks/{react,vue,svelte}(三个官方脚手架真实工程,均 import 同一个 SDK 包)。SDK 包源:applet-sdk/

manifest.json 字段

{
  "slug": "myapp",              // ★必填,全局唯一,决定托管路径 /myapp/
  "name": "我的小程序",          // ★必填,展示名
  "version": "1.0.0",           // ★必填,每次上传须递增
  "scopes": ["user.profile"],   // 申请的能力(见「授权模型」)
  "description": "一句话简介",   // 选填,发现页/详情展示
  "icon": "icon.png",           // 选填,包内相对路径(亦可上传后在控制台换)
  "display": "fullscreen",      // 选填,默认打开方式:window(浮窗,默认)| fullscreen(全屏)
  "fileHandlers": [             // 选填,声明「打开方式」——能处理对话里哪类文件
    { "kinds": ["image"], "role": "editor", "label": "图图修图·修图" }
  ]
}
  • display:声明桌面开屏形态——画板/白板/编辑器类建议 fullscreen,轻量卡片/表单用默认 window。仅作初值,上架后可在控制台「打开方式」随时改(以控制台为准)。移动端始终全屏,不受此字段影响。省略 manifest 时可用 <meta name="tt:display" content="fullscreen"> 兜底。
  • fileHandlers:声明你的小程序能处理对话里的哪类文件——用户在聊天对某条文件点「打开方式」,声明了匹配类型的小程序就会出现在候选里,点选即把该文件直接送进你的小程序(见「处理对话文件」)。每项:kindsimage / video / audio / pdf / office / text / any(可多选,any=任意文件);roleeditor(在编辑器中打开)或 viewer(预览查看);label 选填(≤20 字,候选项显示名)。最多 8 项。生效门控与可见性一致:自用/私享 免审即用(仅你自己的「打开方式」里出现),公开 需管理员审核通过后对所有人生效。

各框架「相对 base」一句话配置:

// Vite (React/Vue/Svelte/Solid/Preact/Lit…)
export default { base: './' }
// SvelteKit(客户端路由 → base 必须=slug)—— 静态导出 + base 设为你的 slug(否则路由 404)
import adapter from '@sveltejs/adapter-static';
export default { kit: {
  adapter: adapter({ fallback: 'index.html' }),
  paths: { base: '/你的slug', relative: true }
} };
// Astro —— astro.config.mjs
export default { base: './', build: { assets: 'assets' } }
# Angular —— 构建时设相对基址
ng build --base-href ./ --output-path dist
// Next.js(静态导出)—— next.config.js
module.exports = { output: 'export', images: { unoptimized: true }, assetPrefix: './' }
// Nuxt 3(静态)—— nuxt.config.ts
export default defineNuxtConfig({ app: { baseURL: './', cdnURL: './' }, ssr: false })
// Vue CLI / webpack —— vue.config.js(或 webpack output.publicPath)
module.exports = { publicPath: './' }
<!-- 原生 / 无构建:直接写相对路径即可 -->
<script src="./app.js"></script>
<link rel="stylesheet" href="./style.css">

⚠ 客户端路由的 SPA(SvelteKit / React Router / Vue Router / Angular) 小程序托管在 /<slug>/ 子路径下。带客户端路由的 SPA 必须把「路由 base」设为你的 slug,否则框架路由匹配不到当前路径 → 整页 404(资源能加载,但路由报 not found)。仅设相对资源 base 不够——那只修资源 URL,不修路由。各框架:SvelteKit kit.paths.base='/<slug>';React Router <BrowserRouter basename="/<slug>">;Vue Router createWebHistory('/<slug>/');Angular APP_BASE_HREF='/<slug>/'。(无客户端路由的应用——纯渲染 / 单页 React 无 Router / 原生——不受影响。)

⚠ 上传体检 上传时平台对 dist 做「契约体检」:入口 / 相对路径 / manifest / SDK 引用 / 限额 / MIME 逐项校验并行内提示。总量 ≤ 8MB、单文件 ≤ 1MB、文件数 ≤ 100、仅白名单 MIME(html/css/js/json/图片/字体/map/wasm)。含字体的产物请压缩子集化以控体积。重量级框架产物(如 tldraw / excalidraw 含 >1MB 单 chunk)超默认单文件/总量限额时,可请运营方在后台调高「单文件字节」「解压总量」上限(运行时可改、即时生效);拆分 manualChunks 亦可把 vendor 压到限额内。

最小示例

一个完整能跑的小程序 —— 引入 SDK,读取用户昵称:

<!doctype html>
<html>
  <body>
    <div id="who">加载中…</div>
    <!-- 相对路径即可,免维护:换域名/被封都无需改代码(平台托管时自动改写为宿主绝对址) -->
    <script src="/applet-sdk.js"></script>
    <script>
      window.tt.ready(function () {
        window.tt.getProfile().then(function (me) {
          document.getElementById('who').textContent = '你好,' + me.nickname;
        });
      });
    </script>
  </body>
</html>
SDK 接入

在小程序 HTML 引入 SDK 脚本,之后即可用 window.tt:

<!-- 在你的小程序 HTML 里引入 SDK。用相对路径即可,别写死域名 -->
<script src="/applet-sdk.js"></script>

不要把宿主域名硬编码进小程序。 写相对的 /applet-sdk.js(或随便一个占位源)即可——平台在 iframe 托管下发时会自动把 SDK 脚本地址改写成当前运行的宿主域。这样即使图图嗨换域名、或某个域名被封,已上架的所有小程序都无需改代码、无需重新发布,由运维改一处配置即全量生效。

就绪回调、上下文与主题/语言:

// 就绪后拿到上下文(appId / 已授权能力 / 深链 path / query / 主题 / 语言 / 是否内联)
window.tt.ready(function (ctx) {
  console.log(ctx.appId, ctx.scopes, ctx.path, ctx.query, ctx.theme, ctx.colorScheme, ctx.locale, ctx.inline);
});
window.tt.context();   // 随时取当前上下文快照(等价 ready 的 ctx)

// 主题切换(宿主明暗切换会实时回调)
window.tt.onThemeChange(function (theme) {
  document.documentElement.setAttribute('data-theme', theme);
});

// 语言切换(随宿主 i18n 同步;宿主切语言会实时回调 —— 与主题同机制)
window.tt.onLocaleChange(function (locale) {   // 如 'zh-CN' / 'en-US'
  document.documentElement.setAttribute('lang', locale);  // SDK 已自动设,可再本地化你的文案
});
框架示例

window.tt 与框架无关,主流框架均可直接使用(单文件、免构建)。每个示例都含成功 ✅ 与失败 ❌(拒绝授权 / 网络异常)处理:

Vanilla JS

// 无框架 —— 原生 DOM
window.tt.ready(function () {
  window.tt.getProfile()
    .then(function (me) {              // ✅ 成功
      document.getElementById('who').textContent = '你好,' + me.nickname;
    })
    .catch(function (err) {            // ❌ 失败(用户拒绝授权 / 网络异常)
      document.getElementById('who').textContent = '获取失败:' + err.message;
    });
});

React

// React 18 + htm(免构建)
const { useState, useEffect } = React;
function App() {
  const [me, setMe] = useState(null);
  const [err, setErr] = useState('');
  useEffect(() => {
    window.tt.ready(() =>
      window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
    );
  }, []);
  if (err) return html`<div>获取失败:${err}</div>`;      // ❌
  return html`<div>你好 ${me ? me.nickname : '…'}</div>`;  // ✅
}

Preact

// Preact + htm(免构建)
const { useState, useEffect } = preactHooks;
function App() {
  const [me, setMe] = useState(null), [err, setErr] = useState('');
  useEffect(() => {
    window.tt.ready(() =>
      window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
    );
  }, []);
  return html`<div>${err ? '获取失败:' + err : '你好 ' + (me ? me.nickname : '…')}</div>`;
}

Vue 3

// Vue 3(CDN)
const { createApp, ref, onMounted } = Vue;
createApp({
  setup() {
    const me = ref(null), err = ref('');
    onMounted(() => window.tt.ready(() =>
      window.tt.getProfile()
        .then((p) => (me.value = p))     // ✅
        .catch((e) => (err.value = e.message))  // ❌
    ));
    return { me, err };
  },
  template: `<div>{{ err ? '获取失败:' + err : '你好 ' + (me?.nickname ?? '…') }}</div>`
}).mount('#app');

Svelte

// Svelte(runtime 编译)
let me = $state(null), err = $state('');
window.tt.ready(() =>
  window.tt.getProfile()
    .then((p) => (me = p))            // ✅
    .catch((e) => (err = e.message))  // ❌
);
// 模板:<div>{err ? '获取失败:' + err : '你好 ' + (me?.nickname ?? '…')}</div>

Solid

// SolidJS
import { createSignal, onMount } from 'solid-js';
function App() {
  const [me, setMe] = createSignal(null), [err, setErr] = createSignal('');
  onMount(() => window.tt.ready(() =>
    window.tt.getProfile().then(setMe).catch((e) => setErr(e.message))
  ));
  return <div>{err() ? '获取失败:' + err() : '你好 ' + (me()?.nickname ?? '…')}</div>;
}

Alpine.js

<!-- Alpine.js:HTML 内声明式,零构建 -->
<div x-data="{ me: null, err: '' }"
     x-init="window.tt.ready(() =>
       window.tt.getProfile()
         .then(p => me = p)              /* ✅ */
         .catch(e => err = e.message))"> <!-- ❌ -->
  <span x-text="err ? '获取失败:' + err : '你好 ' + (me?.nickname ?? '…')"></span>
</div>

Lit

// Lit(Web Components)
import { LitElement, html } from 'lit';
class MyApp extends LitElement {
  static properties = { me: {}, err: {} };
  connectedCallback() {
    super.connectedCallback();
    window.tt.ready(() =>
      window.tt.getProfile()
        .then((p) => (this.me = p))            // ✅
        .catch((e) => (this.err = e.message))  // ❌
    );
  }
  render() {
    return html`<div>${this.err ? '获取失败:' + this.err : '你好 ' + (this.me?.nickname ?? '…')}</div>`;
  }
}
customElements.define('my-app', MyApp);

jQuery

// jQuery
$(function () {
  window.tt.ready(function () {
    window.tt.getProfile()
      .then(function (me) { $('#who').text('你好,' + me.nickname); })      // ✅
      .catch(function (err) { $('#who').text('获取失败:' + err.message); }); // ❌
  });
});

Angular

// Angular(组件)
@Component({ selector: 'app-root', template: `<div>{{ msg }}</div>` })
export class AppComponent implements OnInit {
  msg = '加载中…';
  ngOnInit() {
    const tt = (window as any).tt;
    tt.ready(() =>
      tt.getProfile()
        .then((me: any) => (this.msg = '你好,' + me.nickname))       // ✅
        .catch((e: any) => (this.msg = '获取失败:' + e.message))     // ❌
    );
  }
}

完整可运行示例见仓库 applet-platform/samples/demo-react.htmldemo-svelte.htmldemo-vue.html

数据能力

用户资料 · user.profile
// 获取当前用户资料(需 user.profile;首次调用时宿主按需弹授权)
try {
  const me = await window.tt.getProfile();       // ✅ 成功
  console.log(me.userId, me.nickname, me.avatarUrl);
} catch (err) {                                   // ❌ 失败
  // err.message: "用户拒绝授权"(点了拒绝)/ 网络异常
  console.warn('获取资料失败:', err.message);
}

// 用户资料变化(自己或房间内他人改了昵称/头像)→ 重拉刷新显示
window.tt.onProfileChange(() => refreshWhoUI());
云数据 · cloud.data

托管的结构化集合,让小程序无需自有后端即可持久化业务数据。三档可见性:

  • mine:只读写自己的文档(默认)。
  • all:读写全部,仅开发者(主体)且仅限声明过的集合——见下方「开发者能看到什么」。
  • public:任何登录用户可读全部,集合名须以 pub_ 开头——供社区/市场/论坛;写与改仍限本人。

开发者能看到什么

默认:一个字都看不到。 用户在你的小程序里写的笔记、账目、日记属于他们自己 —— 条数、人数、用量你都看得到(运营需要的数字全在),内容不给

如果某个集合确实是用户提交给你处理的(订单 / 报名 / 工单 / 举报),在 manifest.json 里声明它:

{ "slug": "my-shop", "name": "我的店", "scopes": ["cloud.data"],
  "devReadable": ["orders", "reports"] }

声明之后 scope: 'all' 才对这些集合放行,运营台里也才看得到内容。 ★用户在授权时会逐条看到这份声明 —— 声明越多,他要点的"允许"越重。 只声明你真的需要处理的那些。

用户显示成应用内化名(如 3f2a1b9c),不是他的图图ID 或昵称:同一个人在别的 应用里是另一个编号,你拿不到他的手机号/邮箱。这与后端级应用(X-Tutu-User)是同一套。

数据模式 · dataMode

用户可以自己备份 / 还原 / 导出 / 导入他在你应用里的数据(账号菜单 →「我的数据」)。 默认这一切都通;两种情况要显式声明:

{ "dataMode": "vault" }    // 存档:导出的文件带平台签名,改过之后导不回来(游戏用)
{ "dataMode": "append" }   // 只增:能备份能导出,不能还原 —— 还原会抹掉已发生的记录(账本用)

分类是 game 的应用不写也是 vault,平台自己认。绝大多数应用永远不需要碰这个字段。

// 云数据:无需自有后端,业务数据由图图嗨平台托管。所有调用返回 Promise,务必处理失败。
try {
  // 新建文档(归属当前用户)
  const { id } = await window.tt.cloud.add('orders', { items: cart, total: 68, status: 'pending' });
  // 幂等 upsert:按 docKey 建或改(「一人一票」/ 每用户单条状态最常用,免先查 id 再更新)
  await window.tt.cloud.put('votes', me.userId, { choice: 'A' });
  // 我的文档
  const mine = await window.tt.cloud.list('orders', { scope: 'mine' });
  // 读单条(按 id;不存在返回 null)
  const doc = await window.tt.cloud.get('orders', id);
  // 全部文档(仅开发者/主体,供商家后台;普通用户 → 403)
  const all = await window.tt.cloud.list('orders', { scope: 'all' });
  // 公开集合:集合名以 pub_ 开头 → 任何登录用户可读全部(社区/市场)
  const posts = await window.tt.cloud.list('pub_posts', { scope: 'public' });
  // where 等值过滤(服务端按 data 单字段过滤,大集合下按父键收敛,避免子文档被 200 上限截断)
  const votes = await window.tt.cloud.list('pub_votes', { scope: 'public', where: { pollId: id } });
  // 超过 200 条:listPage 游标翻页(mine/all;可带 where),返回 { docs, nextCursor }
  const pg = await window.tt.cloud.listPage('orders', { scope: 'mine', limit: 100, before: cursor });
  // 字段级更新(owner 或开发者;patch 与原 data 合并)
  await window.tt.cloud.update('orders', id, { status: 'done' });
  // 删除文档(owner 或开发者;幂等)—— 补齐 CRUD,无需再靠软删标记堆积
  await window.tt.cloud.delete('orders', id);
} catch (err) {                                   // ❌ 失败
  // 权限不足(403)/ 非 pub_ 集合用 public(400)/ 配额超限 / 网络
  console.warn('云数据错误:', err.message);
}

读回每行形如 { id, ownerId, mine, data:{...你存的字段}, createdAt, updatedAt },你存的字段都在 data 里(如 row.data.title)。

按条件查 / 计数 / 求和(做真实业务用这三个)

list() 只能「单字段等值 + 建时间倒序 + 单页 200」。统计一定要走服务端: 集合超过单页上限时 list().length少算而且不报错

// 算子:= != > >= < <= in like exists;数字按数字比、字符串按文本比
const { docs, nextCursor } = await window.tt.cloud.query('records', {
  where: [['date', '>=', '2026-08-01'], ['type', '=', 'expense']],
  order: [['date', 'desc']],   // 缺这个字段的文档排最后
  limit: 100,                  // 带上一页的 nextCursor 继续翻
  select: ['date', 'amount']   // 只回这几个字段
});
const { count } = await window.tt.cloud.count('records', { where: [['type', '=', 'expense']] });
const { sum } = await window.tt.cloud.sum('records', { field: 'amount' });
const { groups } = await window.tt.cloud.sum('records', { field: 'amount', groupBy: 'category' });

多端同步 · cloud.onChange

数据按存,换台设备登录同一个图图ID 打开就是同一份。但已经打开着的那一页 不会自己知道数据变了 —— 他在电脑上记了一笔,手机上那一页还开着,显示的仍是十分钟前那份。 加载完数据之后加这一句:

window.tt.cloud.onChange(() => load());   // 别处改了 → 重新拉一次你正在显示的那份
  • 回调参数 { kind, collection, key, op, reason };reason = 'remote'(别处真的写了) 或 'resume'(刚回到前台 / 断网刚恢复,该核对一次)。多数应用不用看它,无脑重拉就对了。
  • 自己写的不会触发(平台按宿主实例去重),放心在回调里重拉;别在回调里写数据
  • 访客只有「回到前台补一次」那一路;老宿主(未升级的嵌入页)不发这个事件,应用照常工作。
  • ⚠ 它是「你自己的数据在别处变了」,不是「谁改了这个集合」——通知按人走, pub_ 公开集合里别人发的内容不会推给你。多人实时(聊天室/协作/对战)用 im.room
KV 存储 · storage.kv

按(小程序,用户)隔离的键值,适合存打卡数、草稿等私有小状态。tt.cloudStorage(setItem/getItem/getKeys/removeItem)是它的 Telegram 式别名。

// 托管 KV(需 storage.kv):按(小程序,用户)隔离,存私有状态
try {
  await window.tt.setStorage('count', 3);
  const n = await window.tt.getStorage('count'); // 3(不存在返回 null)
  await window.tt.removeStorage('count');
  const keys = await window.tt.getStorageKeys();
} catch (err) {                                   // ❌ 配额(≤64 键 / 8KB)/ 网络
  console.warn('存储失败:', err.message);
}

// Telegram 式别名(等价上面,需 storage.kv):
await window.tt.cloudStorage.setItem('draft', '未发送的内容');
const draft = await window.tt.cloudStorage.getItem('draft');   // 不存在返回 null
const ks = await window.tt.cloudStorage.getKeys();
await window.tt.cloudStorage.removeItem('draft');

会话与多人

会话能力 · im.share / im.send / im.read / media.upload

与图图嗨会话交互均由宿主中介(用户主动选择会话),小程序拿不到会话全量列表;im.read 为敏感能力。

// 会话能力均由宿主中介(用户主动选择会话);务必处理"用户取消"与失败。
try {
  // 分享本小程序卡片到会话(需 im.share;不传会话则宿主弹选择器)
  await window.tt.shareToChat({ title: '来投票选午餐 🍜' });
  // 发送文本通知到会话(需 im.send;宿主弹选择器+预览,署名"通过 X 小程序")
  await window.tt.sendMessage('投票结果:兰州拉面胜出');
  // 读取会话消息(需 im.read,敏感;用户逐次主动选择会话,非文本脱敏)
  const r = await window.tt.readMessages({ limit: 30 });
  // 上传图片(需 media.upload;传 dataURL,返回绝对 URL)
  const url = await window.tt.uploadImage(dataUrl);
} catch (err) {                                   // ❌ 失败
  // "用户取消"(选择器/预览取消)/ 拒绝授权 / "调用超时" / 网络
  console.warn('能力调用失败:', err.message);
}
多人房间 · im.room

把「会话」变成实时房间,做小游戏 / 协作:建/入房、房内持久消息实时信令(state 同步,≤2KB、临时不落库)。宿主用你本人身份自动桥接实时帧,宿主 JWT 绝不进小程序;仅限本小程序建的房间 / 被分享入的会话,碰不到用户其它私聊。

// 多人房间(需 im.room):建/入/离房 + 房内持久消息 + 实时信令(≤2KB,临时不落库)。
const { conversationId } = await window.tt.room.create({ title: '五子棋对局' }); // 建房,宿主自动订阅
await window.tt.room.join(conversationId);                 // 幂等入房;宿主自动订阅实时帧
await window.tt.room.subscribe(conversationId);            // 订阅既有会话(如被分享入的群)的实时帧
await window.tt.room.send(conversationId, '开局!');        // 持久文本(不开小程序也可见)
window.tt.room.signal(conversationId, { type:'move', cell:4 }); // 发实时信令(state 同步)
window.tt.room.setTyping(conversationId, true);            // 输入中状态(瞬态)
const members = await window.tt.room.members(conversationId);   // 花名册 [{userId,nickname,avatarUrl,online,isOwner}]
const past = await window.tt.room.history(conversationId, { limit: 50 }); // 重连水合(正序)
await window.tt.room.leave(conversationId);                // 离房(空房间自动回收)

// 实时事件(全部在 tt.room 下):
window.tt.room.onMessage((m) => appendMsg(m));      // 新消息 {conversationId,id,senderId,senderName,kind,text,createdAt}
window.tt.room.onSignal((s) => applyMove(s.payload));// 对手实时动作 {conversationId,senderId,payload}
window.tt.room.onPresence((p) => refreshOnline(p)); // 上/下线 {userId,online}
window.tt.room.onTyping((t) => showTyping(t));       // 输入中 {conversationId,userId,typing}
window.tt.room.onMember(() => reloadMembers());      // 成员进/退房 {conversationId} → 重拉 members()
window.tt.room.onReconnect(() => rehydrate());       // 掉线重连 → 从 history()/cloud 重新水合当前局面

重连水合:信令 best-effort、掉线会丢帧属正常。onReconnect 收到即应用 room.history() 或云数据重新水合最终状态,别只靠信令做唯一真相。

内联组件 · 原生组件式 · 隐私安全

shareToChat({inline:true}) 发出的卡片会直接在聊天气泡内渲染可交互组件(如投票、打分),收件人无需打开浮窗,像原生功能一样操作。小程序据 ctx.inline 渲染紧凑 UI,气泡随内容自适应高度、随宿主明暗实时换肤。隐私:内联实例仅获「仅 cloud.data、无副作用」受限令牌,只能读公开集合 + 写自有文档,碰不到他人私有,也不弹授权。

// —— 内联组件:让小程序像原生功能一样直接在聊天气泡里交互(投票/打分/接龙…)——
// 1) 发一张内联卡到会话(需 im.share):收件人无需打开小程序,直接在气泡内操作
await window.tt.shareToChat({ inline: true, query: { pollId }, title: '投票', height: 200 });

// 2) 小程序据 ctx.inline 渲染两种形态
window.tt.ready((ctx) => {
  if (ctx.inline) {
    renderCompact(ctx.query.pollId);   // 内联:紧凑「原生组件」式 UI
    window.tt.reportHeight();          // 上报高度(SDK 亦自动 ResizeObserver 上报,气泡随内容自适应)
    // 需要完整功能时,从内联卡打开完整页(浮窗/全屏):
    // openBtn.onclick = () => window.tt.openFullPage('/detail?pollId=' + ctx.query.pollId);
  } else {
    renderFull();                      // 浮窗:完整创建 UI
  }
});
// 隐私:内联实例仅获「仅 cloud.data、无副作用」的受限令牌 —— 只能读公开集合 + 写自有文档,
// 碰不到他人私有;不弹授权、不污染授权列表。敏感能力(上传/发消息/定位)内联不可用。
// 暗黑/移动:内联卡随宿主明暗实时切换、宽度自适应,开发者无需额外适配。
嵌入态 · 无缝嵌入 · 自适应高度 · tt.embedded / tt.surface / tt.chromeless

小程序除了独立运行,还会被宿主内联嵌入:聊天气泡内联卡、或站点(图图建站 TutuSite 的「图图应用」组件)把小程序当作页面里的一个区块。嵌入时 SDK 会告诉你嵌入态,并自动把内容真实高度上报给宿主——宿主把 iframe 高度设成你的内容高度,于是由宿主页面滚动、iframe 自身绝不出现滚动条,体验和站内原生组件完全一致。你几乎无需额外适配。

如何判断"是不是被嵌入" + 无缝隐藏头部:

  • JS:window.tt.embedded(是否被内联嵌入,= tt.inline 语义别名)、window.tt.surface(宿主面,五个值之一:'chat' / 'full' / 'store' / 'preview' / 'embed')、window.tt.chromeless(宿主是否要求隐藏你的头部做无缝嵌入)。均在 tt.ready 回调后可用。
  • 纯 CSS(推荐,无需 JS):SDK 会给 <html> 打上类,直接写样式即可:
    • .tt-embedded —— 被嵌入时。可据此隐藏只有独立运行才需要的外壳(顶栏返回、品牌大标题等)。
    • .tt-chromeless —— 宿主要求无缝(隐藏头部)时。隐藏自身头部就靠它
    • .tt-surface-<面>(面名就是 tt.surface 的值,如 .tt-surface-embed)—— 按宿主面微调密度/边距。
/* 无缝嵌入:被嵌入时隐藏独立运行才需要的顶栏;宿主要求 chromeless 时进一步隐藏应用头部 */
.tt-embedded .standalone-only { display: none; }        /* 例:返回按钮、品牌横幅 */
.tt-chromeless .app-header    { display: none; }        /* 宿主要 chromeless → 隐藏你的头部 */
.tt-surface-embed .grid       { gap: 8px; }             /* 别人的网站里更紧凑 */
window.tt.ready((ctx) => {
  if (window.tt.embedded) {
    // 被站点/聊天嵌入:渲染紧凑、无外壳的「区块」形态
    if (window.tt.chromeless) hideHeader();   // 宿主要求隐藏头部(纯 CSS .tt-chromeless 也可)
  }
});

自适应高度铁律(务必遵守,否则被嵌入时会出滚动条/被裁):

  • 嵌入区块的根容器不要写死 height:100vh / height:100% + overflow;让内容自然按文档流撑高,SDK 才能量到真实高度并让宿主全高展示。需要固定播放区/内部滚动区时,用子元素 overflow:auto(SDK 会保留你的内部滚动区不动),别锁死根。
  • 图片给宽高或用 aspect-ratio,避免加载后跳高(SDK 会在图片/字体/视频加载完、窗口 resize 后自动补量,但给尺寸更稳)。
  • 别把内容挤到视口外。用满屏「屏」容器(position:fixed;inset:0 里放 header + 主区 + footer)时,主区高度必须减掉所有兄弟的高度(height:calc(100% - 头高 - 脚高),或直接用 display:flex;flex-direction:column + 主区 flex:1;min-height:0)。只减了头、没减脚,那截 footer 就会落在视口下边缘之外 —— 你量到的内容高会恒等于「自身视口高 + 那一截」,宿主每按它设一次高、下一次就又多出同样一截,块会一路自己长高直到撞上上限(线上真实发生过,每轮 +45px 撑到 4000)。
  • 混合形态要留意:如果你既有撑满视口的定位层(全屏背景/画布),又有比一屏更长的真实内容(关卡列表、说明),让那段真实内容按文档流撑高,别整个塞进 position:fixed 的层里。纯全屏沉浸类(画布/游戏,给多少占多少)不用管——SDK 会认出来,宿主按一屏高展示。
  • 高度自动上报,无需手调;确需手动可调 window.tt.reportHeight()

独立运行(非嵌入)时不会有这些类、tt.embedded===false,你的头部/外壳正常显示——同一份代码两种场景都对。

★你的小程序会在五个地方被打开,能力不完全一样

同一份代码会跑在这五处。判据在 tt.ready(ctx)ctx.surface:

ctx.surface 在哪 谁会看到
chat 图图嗨的聊天里(浮窗/内联卡) 会话里的人
full 运行页 /apps/<你的应用> 从分享链接/应用中心点进来的人
store 图图小程序 / 图图游戏(tutumini / tutuwow) 逛商店的人
preview 开发预览(工作台里那一框) 只有你自己
embed 别人的网站上的嵌入区块 站长的访客

云数据(tt.cloud)、托管 KV(tt.setStorage)、界面提示(toast/modal/看图…)—— 五处都有。 正常写就行,不需要为哪个面做特判。

★★还有第六种情况:你的应用自己的域名(而它没有宿主)

应用有自己的地址(<你的应用>.<平台基域>,或你绑定的自定义域名)。在那上面:

  • 你声明过能力(入口 HTML 的 tt:scopes 里写了 cloud.data / storage.kv 等)→ 平台会带上宿主,tt.* 与运行页里一模一样,你什么都不用做。
  • 你一条能力都没声明 → 平台判定你不需要宿主,于是那个域名整个交给你自己: 没有平台顶栏、没有外层 iframe,就是你的页面。代价是没有宿主 = tt.* 用不了 (包括 tt.showToast 这类不需要授权的)。这时调用会立刻拒绝并告诉你原因, 不会让你干等 15 秒。

⇒ 判据一句话:要用 tt.* 就在 tt:scopes 里声明它。声明之后到处都能用; 一条都不声明,就是一个干干净净的静态站点跑在自己的域名上。 (ctx.surface 在这一档仍是 full —— 它说的是"以完整页面的形态运行", 而"有没有宿主"是另一个维度。)

不一样的是这几样:

  • 发到聊天 / 多人房间 / 多开窗口:只有 chat 有。其余四处没有会话可发、没有窗口管理器。
  • 上传文件(media.upload):chat / full / store 有,previewembed 没有。 两处不给的理由不同:embed 是别人的域名,平台不在上面开上传口;preview 的令牌只带 云数据与 KV(项目还在开发中),上传在那里调不通 —— 要验证上传就 build 之后在运行页试
  • 划词(text.lookup):previewembed 都没有(embed 要接管访客在站长页面上 的选区,越界了;preview 同上,令牌里没有这一项)。
  • 定位 / 拨号:embed 没有(跨源 iframe 要站长在 <iframe allow> 上开权限, 而他并不知道有这回事 —— 与其静默失败,不如明确不给)。
window.tt.ready((ctx) => {
  // 只在真的有这项能力时才渲染那个入口。
  if (ctx.scopes.includes('im.share')) showShareButton();
  // 也可以按面判断:embed 里通常要更紧凑,而且没有"返回"可言。
  if (ctx.surface === 'embed') compactLayout();
});

别把核心流程建在只有 chat 才有的能力上。 用不了的时候平台会明确告诉你为什么 (那些拒绝文案是给开发者看的,照做即可),但一个点了没反应的按钮,用户只会认为 这个应用坏了 —— 他不会知道是因为换了个地方打开。

把你的小程序嵌进任意网站(给站长看的)

只要它是公开的小程序,任何网站都可以嵌一块进去,tt.cloud 这些照常能用 (数据仍然记在图图账号下,访客不登录也能用):

<iframe
  src="https://tutuhai.com/embed/apps/你的应用"
  style="width:100%;border:0;min-height:240px"
  title="应用名"
></iframe>

访客第一次打开时会自动成为这个网站上的访客,记的东西存在云上; 他点右上角「登录」之后,刚才记的会自动转到他的图图账号下(换设备也能看到)。

想让它随内容自适应高度(强烈建议 —— 否则要么留一截空白,要么被裁): 监听它发上来的高度,设给容器即可。

<script>
  addEventListener('message', (e) => {
    if (e.origin !== 'https://tutuhai.com') return;          // 只认这一个源
    if (e.data && e.data.tt === 'viewport') {
      document.querySelector('iframe').style.height = e.data.height + 'px';
    }
  });
</script>

几个可选参数:

参数 作用
?tt_theme=dark 跟随你网站的明暗(不传按浅色)
?tt_locale=en 界面语言(认不出的语言按英文)
?bare=1 隐藏顶部那条身份栏(纯展示型嵌入用;注意访客就没有登录入口了)

平台会记录哪些网站嵌了哪些应用(只记域名,不记你页面的地址)。 如果某个站点出现异常用量,平台可能暂停它的嵌入 —— 那时访客看到的是一句说明 和一个「在图图应用中打开」的按钮,不会是一块空白。

划词查词 / 翻译 · text.lookup / text.provider

划词 popover 本身就是一个「provider 小程序」的内联页——内容/功能全由那个小程序渲染,宿主只给选区 + 悬浮定位 + 灵活 SDK。消费方(让自己小程序内文字可被划词):声明 text.lookup,零代码,划选即在选区正下方弹 provider 内联页(对话消息文本也支持)。提供方(做一个划词小程序):声明 text.provider(经审核授予),内联页用 tt.text.onLookup 收词、自己渲染;哪个当生效 provider 由后台配置,未配/未授权则划词不启用。退订区域加 data-tt-no-lookup

// ── A. 让「你的小程序内的文字」可被划词(消费方,需 text.lookup)──【零代码】
// manifest 声明 "text.lookup" 后,用户在你的小程序里划选文字,即在选区正下方弹出
// 【provider 小程序的内联页】popover(自动出词典/翻译)。对话消息文本也同样支持(宿主内置)。
// 点空白/滚动/Esc 隐藏;popover 内操作不关闭。无需写任何 JS。
// 退订:不想被划词的区域加 data-tt-no-lookup;<input type=password> 自动排除。

// ── B. 做一个「划词 provider 小程序」(提供方,需 text.provider,经审核授予)──
// provider 页(内联模式)用 onLookup 接收宿主推来的划词文本(初始+每次换词都推,常驻不重载),
// 自己查/翻/渲染成任意 UI;可用 tt.text.lookup 调内置引擎,或用自有 cloud.data 词典。
tt.ready(function () {
  tt.text.onLookup(function (text) {          // 宿主推划词文本
    tt.text.lookup(text).then(function (r) {  // r={kind:'dict'|'translate',...}
      render(r);                              // 渲染成你自己的界面(高度自适应)
    });
  });
});

// ── 逃生口 / 灵活 SDK 原语(任意小程序可用)──
const r = await window.tt.text.lookup('lazy', { to:'en' });        // 主动查词/翻译
window.tt.text.onSelect(function (sel){ /* {text, rect};注册即接管,默认 popover 让位 */ });
window.tt.openFloating({ path:'/detail', anchor: sel.rect, width:320, height:220 }); // 选区处开悬浮全窗
window.tt.floating.moveTo(100, 200);  // 悬浮窗位置/尺寸 SDK 全程可调:setRect/moveTo/resize/close

文件与云盘

处理对话文件 · media.upload / im.share

用户在聊天里对某条图片/文件点「打开方式」,即可选你的小程序处理它——前提是你在 manifest.json 的 fileHandlers 里声明了匹配的文件类型(image/video/audio/pdf/office/text/any)。打开后:getContextFile() 拿到文件,readFile() 由宿主同源代取字节(规避跨域,可分析图片/音视频/任意格式),处理后 uploadFile() + sendFileToChat() 发回对话或 saveFile() 下载——无缝一条龙。

// —— 处理对话文件:用户对某条文件点「打开方式」选中你的小程序进入(须在 manifest.fileHandlers 声明匹配 kinds)——
const f = window.tt.getContextFile();
// f = {url,name,mime,size,kind:'image'|'file',conversationId,messageId} 或 null(独立打开时)
if (f) {
  const bytes = await window.tt.readFile(f.url);   // 宿主同源代取字节(规避 iframe 跨域;仅限本站 /uploads 产物)
  // bytes = {dataUrl, mime, name, size, url} —— 可喂 <img>/<video>/<audio>/canvas 分析任意格式
  imgEl.src = bytes.dataUrl;
}
// —— 处理产物 → 发回对话 或 下载(需 media.upload / im.share)——
const out = canvas.toDataURL('image/webp', 0.9);            // 例:图片转 WebP
const up = await window.tt.uploadFile({ dataUrl: out, name: 'result.webp' });   // {url,name,size,mime}(≤20MB)
await window.tt.sendFileToChat({
  url: up.url, name: up.name, mime: up.mime, size: up.size,
  conversationId: f.conversationId    // 带上则直发原会话(跳过选择器);不带则宿主弹会话选择器
});
await window.tt.saveFile({ dataUrl: out, name: 'result.webp' });   // 或:下载到本地(宿主代下)
图图云盘 · tt.drive(需 media.upload

图图云盘选文件进来、或把产物存进云盘(host-mediated:用户在宿主页面内的云盘选择器里逐次选取,小程序不持云盘 token)。云盘不可达 / 未联邦图图 ID 时 reject —— 小程序 .catch 后可回退到本地 uploadFile

// 从云盘选文件(用户在宿主云盘选择器里挑;取消返 []):
const picked = await window.tt.drive.pick({ multiple: true, accept: 'image/*' });
// picked = [{ nodeId, name, size, mime, downloadUrl }]
for (const file of picked) {
  const bytes = await window.tt.readFile(file.downloadUrl);  // 取字节分析/展示
  render(bytes.dataUrl);
}

// 把文件存进云盘(pullUrl 传本站 /uploads 产物绝对化,或直接 dataUrl):
const saved = await window.tt.drive.save({ pullUrl: up.url, name: '导出结果.png' });
// saved = { nodeId, name }

多程序联动

多程序联动 · 互拖 · tt.link / tt.tray / tt.drag / tt.drop(无需授权)

多个小程序可同时打开(可存为组合一键同开、多窗 2/3/4 布局、边栏常驻——这些均由宿主管理,小程序无需写码),并用下列能力彼此联动:

  • tt.link:与同时打开的其它小程序实时同步事件/状态(广播或定向,≤2KB、限流 25/s;关窗即断,不落库、不跨用户/会话)。
  • tt.tray:把内容拿起放入宿主托盘中转,再注入别的小程序或对话。
  • tt.drag.start / tt.drag.bind:发起拖拽 / 把元素绑成可直接拖到别的小程序的拖源(同源小程序间走原生 HTML5 拖放)。
  • tt.drop.accept:整个小程序都能接收拖入 / 托盘注入。
  • tt.drop.zone:★让内部某个具体元素感知拖入(悬停高亮反馈)并在其上产生效果。一个小程序可有多个 zone,各自独立感知。

安全:file 包裹的 url 仅接受本站 /uploads/ 句柄(收方用 tt.readFile 取字节),伪造的跨源外链会被丢弃;text/json/name 均有大小上限。

// —— ① 事件/状态同步 tt.link(与"同时打开的其它小程序"实时广播;关窗即断,不落库、不跨用户/会话)——
tt.link.send({ type: 'color', color: '#ef4444' });        // 广播给所有联动的小程序(≤2KB,限流 25/s)
tt.link.sendTo(appId, { type: 'ping' });                  // 定向发给某个 peer
tt.link.on((from, msg) => { /* from={appId,slug,name} */ apply(msg); });
const peers = await tt.link.peers();                      // 当前联动的其它小程序 [{appId,slug,name}]

// —— ② 托盘中转 tt.tray(拿起 → 放到别的小程序 / 对话)——
await tt.tray.put({ kind:'json', name:'颜色', data:{ color:'#ef4444' } }); // 放入宿主托盘
const items = await tt.tray.list();                       // 查看托盘 [parcel]
const p = await tt.tray.take(id);                         // 取走某个包裹
// 包裹 parcel = { kind:'file'|'text'|'json', url?/text?/data?, name?, mime? }

// —— ③ 直接互拖【拖源】tt.drag ——
tt.drag.start({ kind:'json', name:'颜色', data:{ color:'#ef4444' } }); // 主动发起一次拖拽,返回 {id}
tt.drag.bind(swatchEl, () => ({ kind:'json', name:'颜色', data:{ type:'color', color:'#ef4444' } }));
// getParcel() 返回本次拖拽的包裹;返回 null 则不发起。同源小程序间用原生 HTML5 拖放,浏览器自带跟手 ghost。

// —— ④ 接收【整窗】tt.drop.accept ——
tt.drop.accept(['json','file'], (parcel) => { apply(parcel); }); // 拖到本窗任意处 / 托盘注入都回调

// —— ⑤ ★接收【元素级】tt.drop.zone ——
tt.drop.zone(slotEl, ['json','file'], {
  onEnter: () => slotEl.classList.add('hot'),    // 拖入这个元素 → 只有它高亮(内部元素各自感知)
  onOver:  () => {},                             // 悬停中(可做持续反馈)
  onLeave: () => slotEl.classList.remove('hot'), // 移出 → 取消高亮
  onDrop:  (parcel) => fill(slotEl, parcel),     // 落在这个元素上 → 仅它接收并产生效果
});

界面 · 窗口 · 宿主

界面 · 窗口 · 设备(无需授权)

仿微信 wx.* UI 能力,由宿主渲染真实的提示/对话框/图片查看器、控制小程序胶囊窗口、访问剪贴板/震动/拨号/位置/网络——无需申请授权即可调用,让小程序像原生 App 一样强大。

// —— 交互反馈 ——
window.tt.showToast({ title: '已保存', icon: 'success' });   // icon: success|error|loading|none
window.tt.hideToast();
window.tt.showLoading({ title: '处理中…' });                 // 配对 window.tt.hideLoading()
window.tt.hideLoading();
const { confirm } = await window.tt.showModal({ title: '确认', content: '要删除吗?' });
const { tapIndex } = await window.tt.showActionSheet({ itemList: ['拍照', '相册'] }); // 取消则 reject
// —— 窗口 / 系统信息 ——
window.tt.setNavigationBarTitle({ title: '我的页面' });      // 改小程序胶囊标题
const info = await window.tt.getSystemInfo();                // {theme, platform, windowWidth, windowHeight, safeAreaInsets, appName, version}
// —— 设备 ——
await window.tt.setClipboardData({ data: '复制的文本' });
const { data } = await window.tt.getClipboardData();
window.tt.vibrateShort();  window.tt.vibrateLong();          // 触感反馈
window.tt.makePhoneCall({ phoneNumber: '10086' });
// —— 媒体 ——
window.tt.previewImage({ urls: [url1, url2], current: url1 }); // 全屏图片预览
// —— 位置 / 外链 / 网络 ——
const loc = await window.tt.getLocation();                    // 浏览器弹权限 → {latitude, longitude, accuracy, speed}
window.tt.openLocation({ latitude: loc.latitude, longitude: loc.longitude, name: '门店' }); // 地图查看
window.tt.openLink({ url: 'https://example.com' });           // 新标签打开(仅 http/https)
const net = await window.tt.getNetworkType();                 // {isConnected, networkType: wifi|4g|...}
悬浮窗 · 全屏 · 品牌化

控制小程序窗口:内联/浮窗打开悬浮全窗、展开全屏 / 还原、给宿主胶囊上色,并监听显示/尺寸变化。

// —— 悬浮窗(从内联/选区处开一个可拖动悬浮窗)——
window.tt.openFloating({ path:'/detail', anchor: rect, x:100, y:120, width:320, height:220 });
window.tt.floating.setRect({ x, y, width, height });  // 也可 moveTo(x,y) / resize(w,h) / close()
window.tt.openFullPage('/detail');                    // 从内联卡打开完整页(浮窗/全屏)

// —— 全屏 / 还原 / 关闭(桌面;移动端本就全屏)——
window.tt.expand();  window.tt.collapse();  window.tt.close();
const dm = await window.tt.getDisplayMode();          // {maximized, mobile} —— 是否全屏 / 是否移动端
window.tt.onEvent('displayChanged', (p) => updateFullscreenChip(p.maximized)); // 与宿主胶囊「全屏/还原」双向联动
window.tt.onEvent('viewportChanged', (p) => relayout(p.width, p.height));       // iframe 尺寸变化 → 响应式重排

// —— 品牌化:给宿主胶囊头部/背景上色 ——
window.tt.setHeaderColor('#4f46e5');
window.tt.setBackgroundColor('#fdf6e3');
宿主按钮 · 触感 · Telegram 式(无需授权)

小程序控制宿主 chrome 的按钮并接收其点击事件(双向)——底部主按钮 mainButton、头部返回键 backButton、触感 hapticFeedback。小程序由此深度集成宿主 UI(而非孤立页面)。

// 主按钮 MainButton(宿主底部大按钮,小程序控制 + 接收点击)—— 仿 Telegram
window.tt.mainButton.setText('提交订单').show();      // 链式;setText/setParams/show/hide/enable/disable/showProgress/hideProgress
window.tt.mainButton.onClick(() => {                  // 点击回调(宿主 → 小程序 事件);offClick 解绑
  window.tt.mainButton.showProgress();
  submit().finally(() => window.tt.mainButton.hideProgress());
});
// 返回按钮 BackButton(宿主头部返回键)
window.tt.backButton.show();                           // show/hide/onClick/offClick
window.tt.backButton.onClick(() => history.back());
// 通用事件监听(等价上面的 onClick)
window.tt.onEvent('mainButtonClicked', handler);
window.tt.onEvent('backButtonClicked', handler);
window.tt.offEvent('mainButtonClicked', handler);      // 解绑
// 触感反馈 HapticFeedback
window.tt.hapticFeedback.impactOccurred('light');      // light|medium|heavy|rigid|soft
window.tt.hapticFeedback.notificationOccurred('success'); // error|success|warning
window.tt.hapticFeedback.selectionChanged();
主题 · 云存储 · Telegram 式

colorScheme / themeParams 让小程序与宿主配色一致并随明暗切换;locale 随宿主 i18n 同步。

// —— 主题(与宿主配色一致,随明暗切换)——
window.tt.colorScheme;          // 'light' | 'dark'
window.tt.themeParams;          // {bgColor,textColor,hintColor,linkColor,buttonColor,buttonTextColor,secondaryBgColor}
document.body.style.background = window.tt.themeParams.bgColor;   // 用宿主色,与宿主一致
window.tt.onEvent('themeChanged', () => {                        // 宿主切换明暗时触发(等价 onThemeChange)
  applyTheme(window.tt.colorScheme, window.tt.themeParams);
});
// —— 语言 i18n(随宿主同步;SDK 已自动设 <html lang>)——
window.tt.locale;               // 如 'zh-CN' / 'en-US';等价 tt.context().locale
window.tt.onLocaleChange((locale) => renderInLang(locale));      // 或 onEvent('localeChanged')
自适应:暗黑 · 多语言 · 屏幕尺寸(务必支持)

好的小程序完全自动适配宿主:跟随主站的暗黑/浅色、当前语言、以及 PC / 移动屏幕尺寸。宿主在两处给你信号——iframe URL 首帧提示(防闪白)+ init/事件(运行中实时切换),你据此渲染即可。

① 暗黑(dark)——首帧不闪白 + 运行中实时切换

/* 用 [data-theme] 或 .dark(SDK 加载即同步设到 <html>,首帧就对)——别只靠 prefers-color-scheme */
:root { --bg:#fff; --fg:#0a0a0a; }
html[data-theme="dark"], html.dark { --bg:#0f0f12; --fg:#ededed; }
body { background: var(--bg); color: var(--fg); }
tt.ready((ctx) => applyTheme(ctx.theme));              // 首帧当前主题('light'|'dark')
tt.onThemeChange((theme) => applyTheme(theme));        // 用户在主站切暗黑 → 运行中实时回调(不重载,保状态)

② 多语言(i18n)——跟随主站语言

tt.ready((ctx) => renderInLang(ctx.locale));           // 首帧当前语言(如 'zh-CN' / 'en' / 'ja')
tt.onLocaleChange((locale) => renderInLang(locale));   // 主站切换语言 → 实时回调

宿主已自动把 <html lang> 设为当前语言,可配合 CSS :lang()。你的文案按 ctx.locale 取对应语言即可(主站小程序应内置至少中/英)。

③ 屏幕尺寸——PC / 移动同一套自适应

用响应式 CSS(移动优先 + @media/flex/grid),别写死像素宽。窗口尺寸变化(拖拽浮窗、旋屏、PC↔移动)由浏览器直接反映到你的布局。需要主动判断时:

tt.getSystemInfo().then(({ windowWidth, windowHeight, theme }) => layout(windowWidth));
tt.getDisplayMode().then(({ maximized, mobile }) => { /* 移动端恒全屏;桌面浮窗/全屏 */ });
tt.onEvent('displayChanged', ({ maximized }) => relayout()); // 全屏/浮窗切换实时联动

三者叠加即「主站小程序完全自动适配」:同一份代码在 PC 浅色、移动暗黑、任意语言下都正确。独立运行页 /apps/<slug> 与聊天内运行时用的是同一套信号,一次适配处处生效。

参考

错误处理

所有 window.tt.* 都返回 Promise,失败时 reject 一个 Error。生产小程序务必对每个调用做 .catch / try-catch,常见 err.message:

err.message 含义 / 处理建议
用户拒绝授权 按需授权弹窗被拒 → 引导重试
用户取消 会话选择器/预览被取消 → 静默
调用超时 宿主长时间无响应(罕见)→ 提示重试
数据过大 / 文档过多 / 存储项过多 配额超限 → 精简数据
public 读取仅限 pub_ 前缀的公开集合 集合命名不符 → 改用 pub_ 前缀
禁止访问(403) 非开发者用 scope=all → 无权限
网络异常 / Failed to fetch 网络故障 → 友好提示 + 重试
// 所有 tt.* 都返回 Promise,失败会 reject 一个 Error;用 .catch / try-catch 处理。
window.tt.getProfile()
  .then((me) => { /* … */ })
  .catch((err) => {
    switch (err.message) {
      case '用户拒绝授权': /* 引导用户重试授权 */ break;
      case '用户取消':     /* 用户取消了会话选择,静默即可 */ break;
      case '调用超时':     /* 宿主长时间无响应(罕见),提示重试 */ break;
      default:            /* 配额 / 权限(403)/ 网络等,给友好提示 */
    }
  });
授权模型

按需授权:打开小程序无需上来授全部权限;某能力首次调用时宿主才弹单项征询(允许/拒绝)。用户可「信任该小程序」一次授予全部,或在设置页逐项开关、查看使用记录。后端每次仍二次校验(纵深防御)。界面/窗口/设备/主题/多程序联动等无需授权即可用。

能力(scope) 说明 敏感
user.profile 获取你的昵称和头像
cloud.data 云数据(集合;订单/记录等)
storage.kv 数据存储(KV)
media.upload 上传图片/文件(含云盘 tt.drive)
im.share 分享卡片到聊天
im.send 发送消息
im.read 读取会话历史 敏感
im.room 多人房间:代你在房间收发消息并读取房间聊天 敏感
text.lookup 划词查词翻译(所选文字将发送到翻译服务) 敏感
text.provider 划词提供方(本小程序页作为划词/翻译 popover) 敏感
可见性
  • 公开(public):出现在「发现」与搜索,任何人可找到。
  • 私有(unlisted):不进发现/搜索,只能通过分享卡片或复制链接(深链)进入——用于私域流量传播、不公开曝光。可在控制台一键切换。
  • 打开方式(桌面):控制台「打开方式」可切「浮窗(默认)/ 全屏」——画板/白板/编辑器类设 fullscreen 开屏即铺满,轻量卡片/表单用浮窗。亦可在 manifest.jsondisplay 声明初值。移动端始终全屏,不受此设置影响。
应用中心 · 独立运行页 · 免登录

公开(public)且过审的小程序会自动进入应用中心 /apps(可被搜索引擎收录),并各自拥有独立运行页 /apps/<slug>:有自己的 URL、SEO(标题/描述/结构化数据),可直接分享给站外用户,打开即用、无需登录

三档免登录(由平台/后台按应用配置):

档位 行为 适合
纯静态 匿名加载即跑,不调任何带 token 的能力 无需存档的工具/小游戏
渐进式(默认) 匿名先玩;调到「需身份」的能力(存档/上传等)时,当场轻量登录,不打断 大多数应用
完整访客态(后台开启) 匿名也能存档:平台发访客 token + 独立访客云命名空间;登录后一键把访客数据认领到账号 需要保存进度/记录的游戏/工具

独立运行页的能力边界(与聊天内运行时的区别):独立页专注「算 + 云存 + 划词」——cloud.datastorage.kvtext.lookupuser.profile 正常可用(访客态仅 cloud.data+text.lookup);而社交/媒体递交(im.share 发到聊天、im.room 多人房间、tt.drive 云盘、media.upload 等)需在图图嗨对话内使用,独立页会提示用户回到聊天。据此设计:核心玩法/数据放前者,分享/多人放后者。

要让你的应用出现在应用中心并支持访客态,把可见性设为公开并通过审核;访客态由管理员在后台「应用中心设置」中按应用开启(含访客配额/保留天数)。配合上面的自适应,站内外、登录与否、任意语言与明暗下体验一致。

版本与发布

版本模型:草稿 → 审核中 → 待上架 → 线上

保留全部历史版本,支持一键回滚到任一历史版本(瞬时切换线上)。被驳回会在通知中心看到驳回原因。

约束与配额
  • 云数据:单文档 ≤ 8KB,每(小程序,用户)≤ 500 文档,list() 单次 ≤ 200(按最新);更多用 listPage() 游标翻页(mine/all)。注意:直接用 list() 做前端聚合(计数/均分)在集合 >200 时会漏算最旧部分、结果偏低——需全量时改用 listPage 翻页或接受近似。
  • KV:单值 ≤ 8KB,每(小程序,用户)≤ 64 键。
  • 上传图片 ≤ 4MB;上传文件 ≤ 20MB;发送消息限流(每用户每分钟 ≤ 20 条);房间信令 payload ≤ 2KB;tt.link 单条 ≤ 2KB、节流 25/s。
  • 代码包为单文件 HTML(建议纯 DOM 渲染、避免 innerHTML 以防 XSS)。
  • 令牌短命(约 2 小时),过期后宿主静默续签;能力调用后端二次校验。

参考示例:仓库 applets/food(点餐,云数据)、applets/repair(报修)均为纯前端 + 云数据小程序。