关于 basic-custom-app-build-w-video
用于构建或接手 Domo 自定义应用并制作 Remotion 演示视频的开发手册。它加入确定性合成样例数据和 Remotion 样式约束,使应用组件无需实时 Domo 数据即可用于视频渲染。
主要功能
- 01Domo 应用构建阶段指引
- 02Remotion 兼容样式规范
- 03确定性合成样例数据
- 04将应用组件渲染为演示视频
- 05由属性驱动的视觉状态
适用场景
- 01构建带利益相关方演示的新 Domo 应用
- 02为现有应用制作可视化演示
- 03无需实时数据集或凭据即可渲染应用视图
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ stahura/ domo-ai-vibe-rules --skill basic-custom-app-build-w-video安装后在 Claude Code 中输入/basic-custom-app-build-w-video确认已加载。
详细介绍
Domo 应用 + 演示视频构建指南
适用场景
- 构建新的 Domo 自定义应用,并为利益相关方、文档或营销制作演示视频。
- 接手现有应用,在规范化的同时制作可视化演示。
与 basic-custom-app-build 的区别
本指南在标准 Domo 应用构建流程的基础上增加了三项内容:
- 强制执行兼容 Remotion 的样式约束,从阶段 0 开始,确保应用 UI 可在 Remotion 中直接截图并制作动画,无需返工。
- 生成示例数据(阶段 3A),提供逼真的模拟数据,让演示呈现填充了内容的 UI,无需实时 Domo 数据集或凭据。
- 制作演示视频(阶段 10),使用 Remotion 为完成的应用制作一段精美的 10-15 秒视频。
进度检查清单
复制此检查清单,并在工作过程中更新。
Demo-Ready Build Progress:
- [ ] Phase 0 : Rules loaded (platform + Remotion styling constraints)
- [ ] Phase 1 : Manifest & contracts
- [ ] Phase 2 : App shell (domo.js)
- [ ] Phase 3 : Data access
- [ ] Phase 3A: Sample data generation
- [ ] Phase 4 : App storage (if needed)
- [ ] Phase 5 : Toolkit clients (if needed)
- [ ] Phase 6 : Feature skills (AI / Code Engine / Workflow — as needed)
- [ ] Phase 7 : UI build (Remotion-safe styling)
- [ ] Phase 8 : Performance review
- [ ] Phase 9 : Build & publish
- [ ] Phase 10: Demo video (Remotion)
- [ ] Phase 11: Verification
阶段 0 — 加载规则(始终启用)
编写任何代码之前,先应用以下规则:
rules/core-custom-apps-rule.mdrules/custom-app-gotchas.md
Remotion 样式约束(所有阶段均须遵守)
这些规则可确保你构建的每个 UI 组件都能通过截图捕获,或直接在 Remotion 合成中渲染,无需返工。
| 规则 | 原因 |
|---|---|
不要使用 CSS transition-* 或 animation-* 属性 | Remotion 无法捕获由 CSS 驱动的动画。演示中的所有动画都将由 useCurrentFrame() 驱动。静态样式(颜色、边框、阴影、静止状态下的变换)没有问题。 |
不要使用 Tailwind transition-* 或 animate-* 工具类 | 原因相同。其他 Tailwind 工具类均可使用,且建议使用。 |
使用内联 style 对象或 Tailwind 进行布局 | Remotion 会将 React 组件渲染为帧;CSS 模块和作用域样式可以正常使用,但 CSS-in-JS 运行时库(styled-components、Emotion)可能会在 Remotion 渲染流程中导致水合问题。优先使用内联样式或 Tailwind。 |
在 Domo 应用中正常使用 <img> | 在 应用 代码中,使用标准 <img> 标签。Remotion 演示在合成场景时会使用自己的 <Img> 组件。此约束旨在避免依赖延迟加载或交叉观察器模式,因为这些模式在 Remotion 帧捕获时不会触发。 |
| 保持视觉状态确定性 | 演示中展示的每种 UI 状态都必须仅根据 props/数据即可复现,而不能依赖用户交互历史或计时器。构建组件时,应确保传入示例数据即可生成演示所需的精确视觉效果。 |
| 按 1920x1080 进行设计 | 确保应用布局在 1920x1080 分辨率下显示良好,因为这是演示视频的原生分辨率。仍可采用响应式设计,但请务必验证 1080p 断点。 |
阶段 1 — 清单与契约
使用 manifest。
首先定义所有外部资源映射:数据集、集合、工作流、Code Engine 包。其他所有内容都依赖这些映射。
仅使用安全别名名称(^[A-Za-z][A-Za-z0-9]*$),不得包含空格或特殊字符。
检查根目录中是否已有 thumbnail.png,并将其复制到应用的 public/ 文件夹。
接手现有应用? 修改前,先审查当前 manifest.json,确认其与实际代码用法一致。
阶段 2 — 应用框架
使用 domo-js。
设置基础内容:导入 ryuu.js、通过 domo.navigate() 导航、添加事件监听器、获取环境信息。
DA CLI 用户: 如果用户明确提出要求,也使用 da-cli 搭建脚手架。
阶段 3 — 数据访问
使用 dataset-query(主要)和 data-api(路由概览)。
使用 @domoinc/query 构建查询。在编写 UI/数据映射逻辑之前,先获取 manifest.json datasetsMapping 中每个数据集的真实架构,并创建明确的字段映射。
需要原始 SQL? 使用 sql-query,但请注意,SQL 会忽略页面筛选器。
阶段 3A — 生成示例数据
这是确保应用适合演示的关键补充。生成逼真的示例数据,要求:
- 填充演示中展示的所有视图(仪表板、详情面板、图表、表格、聊天消息等)
- 不包含任何真实的客户或用户数据——所有姓名、电子邮件、ID 和指标均为合成数据
- 具有确定性——相同的种子会生成相同的数据,因此截图和 Remotion 渲染结果可复现
实现方法
在应用中创建一个 src/sample-data/ 目录,其中包含:
src/sample-data/
index.ts # Re-exports all sample datasets; single import point
datasets/
<alias>.ts # One file per manifest dataset alias, exporting typed arrays
README.md # Documents the shape and purpose of each sample dataset
对于 manifest.json 中的每个数据集别名:
- 创建一个 TypeScript 文件,导出与阶段 3 中该数据集字段映射相匹配的类型化数组。
- 生成 20-100 行逼真的数据(足以令人信服地填充图表和表格)。
- 使用数据集架构中的实际字段名称,而非猜测的名称。
- 包含一定范围的值,以呈现有趣的视觉模式(趋势、异常值、类别分布)。
// src/sample-data/datasets/sales.ts
export type SalesRow = {
Date: string;
Region: string;
Product: string;
Revenue: number;
Units: number;
};
export const salesData: SalesRow[] = [
{ Date: "2026-01-15", Region: "West", Product: "Widget A", Revenue: 12400, Units: 62 },
{ Date: "2026-01-16", Region: "East", Product: "Widget B", Revenue: 8750, Units: 35 },
// ... 20-100 rows with realistic distribution
];
将示例数据接入应用
创建一个数据访问层,在实时 Domo 查询和示例数据之间切换:
// src/data/use-dataset.ts
import { salesData } from "../sample-data/datasets/sales";
const USE_SAMPLE_DATA = !window.__DOMO_ENV__; // true outside Domo runtime
export async function fetchSalesData(): Promise<SalesRow[]> {
if (USE_SAMPLE_DATA) {
return salesData;
}
// Real query via @domoinc/query
return new Query().select([...]).fetch("sales");
}
这样可以确保:
- **在 Domo 中:**应用会照常查询真实数据集。
- **在本地 / Remotion 中:**应用使用示例数据进行渲染,无需凭据。
- **安全性:**演示视频中不会包含任何真实数据。
非数据集来源的示例数据
| 来源 | 示例数据策略 |
|---|---|
| AppDB 集合 | 在 src/sample-data/collections/<name>.ts 中导出示例文档数组 |
| AI 响应 | 在 src/sample-data/ai-responses.ts 中导出预设响应字符串 |
| 用户身份 | 导出模拟用户对象:{ displayName: "Alex Demo", email: "[email protected]", role: "Admin" } |
| 工作流结果 | 在 src/sample-data/workflows/<name>.ts 中导出示例状态对象 |
阶段 4 — 应用存储(如有需要)
需要创建存储时,使用 appdb 和 appdb-collection-create。
如果应用只读取数据集,则跳过。
阶段 5 — Toolkit 客户端(如有需要)
使用 toolkit。
在能发挥作用的地方,使用类型化的 @domoinc/toolkit 客户端。
阶段 6 — 功能技能(按需)
只加载应用实际需要的技能:
| 所需功能 | 技能 |
|---|---|
| AI 文本生成或文本转 SQL | ai-service-layer |
| 服务端函数(密钥、外部 API) | code-engine + code-engine-create + code-engine-update |
| 触发自动化工作流 | workflow |
如果不需要这些功能,则跳过此阶段。
阶段 7 — 构建 UI(兼容 Remotion 的样式)
构建应用的 UI 组件。这是标准的 React 开发,但必须遵守阶段 0 中的 Remotion 约束:
- 所有样式均使用 Tailwind 工具类或内联
style对象。 - 不使用
transition-*、animate-*或 CSS 关键帧动画。 - 所有视觉状态都必须能通过属性 + 示例数据(阶段 3A)复现。
- 在 1920x1080 分辨率下检查布局。
- 演示中展示的每个“场景”(加载状态、已填充内容的仪表板、详情视图等),都应能通过传入不同的示例数据来呈现,而不是通过模拟用户点击来实现。
便于演示的组件设计
对组件进行结构化设计,使每种视觉状态都由数据决定:
// Good: demo can render any state by passing props
<ChatPanel messages={sampleMessages} isTyping={false} />
<ChatPanel messages={sampleMessages} isTyping={true} />
// Bad: state is internal and requires user interaction to reach
<ChatPanel /> // internally manages messages via useState
无需按这种方式重构整个应用,只需处理演示中需要展示的视图即可。将 3-5 种关键视觉状态作为由属性驱动的变体公开。
阶段 8 — 性能审查
使用 performance。
检查所有查询。查看是否存在全量数据集提取、缺少聚合或不必要的列。
阶段 9 — 构建并发布
使用 publish。
npm run build -> cd dist -> domo publish。首次发布时,将生成的 id 复制回源清单。
阶段 10 — 演示视频(Remotion)
演示视频会直接在 Remotion 中渲染实际的应用组件——不是截图、标题卡片或营销幻灯片。观众应看到填充了示例数据的真实 UI,并通过滚动动画或视图切换来展示应用的所有部分。
10.1 — 搭建 Remotion 项目
在应用源代码旁创建一个 demo/ 目录:
my-app/
src/ # Domo app source
demo/ # Remotion demo project
src/
Root.tsx
DemoVideo.tsx
index.ts
remotion.config.ts # Webpack alias to import app components
package.json
package.json
演示项目必须包含应用使用的相同 UI 依赖(例如 recharts),这样 Remotion 才能渲染实际组件:
{
"dependencies": {
"@remotion/cli": "^4.0.0",
"@remotion/transitions": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.12.0",
"remotion": "^4.0.0"
}
}
添加应用使用的其他 UI 库(图表库、UI 组件套件等)。
remotion.config.ts — 应用导入的 Webpack 别名
这一点至关重要。配置 @app 别名,以便演示代码可以直接从应用的 src/ 导入:
import { Config } from '@remotion/cli/config';
import path from 'path';
Config.overrideWebpackConfig((currentConfiguration) => ({
...currentConfiguration,
resolve: {
...currentConfiguration.resolve,
alias: {
...currentConfiguration.resolve?.alias,
'@app': path.resolve(process.cwd(), '..', 'src'),
},
},
}));
index.ts — Remotion 入口点
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);
tsconfig.json — 路径映射
{
"compilerOptions": {
"paths": { "@app/*": ["../src/*"] },
"baseUrl": "."
},
"include": ["src", "../src"]
}
10.2 — AnimatedCursor 组件
创建一个可复用的光标,使其在不同位置间移动并显示点击脉冲,以营造有人正在使用应用的效果。
放置在 demo/src/components/AnimatedCursor.tsx:
import React from 'react';
import { useCurrentFrame, useVideoConfig, interpolate, spring, Easing } from 'remotion';
export type CursorWaypoint = {
x: number; // Pixel position from left
y: number; // Pixel position from top
atFrame: number; // Frame at which cursor should arrive here
click?: boolean; // Show a click pulse when arriving
};
type Props = {
waypoints: CursorWaypoint[];
size?: number; // Default 20
};
export const AnimatedCursor: React.FC<Props> = ({ waypoints, size = 20 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
if (waypoints.length === 0) return null;
// Find which two waypoints we're interpolating between
let fromWp = waypoints[0];
let toWp = waypoints[0];
for (let i = 0; i < waypoints.length - 1; i++) {
if (frame >= waypoints[i].atFrame) {
fromWp = waypoints[i];
toWp = waypoints[i + 1];
}
}
if (frame >= waypoints[waypoints.length - 1].atFrame) {
fromWp = waypoints[waypoints.length - 1];
toWp = fromWp;
}
const easing = Easing.inOut(Easing.quad);
const x = fromWp === toWp ? fromWp.x
: interpolate(frame, [fromWp.atFrame, toWp.atFrame], [fromWp.x, toWp.x],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing });
const y = fromWp === toWp ? fromWp.y
: interpolate(frame, [fromWp.atFrame, toWp.atFrame], [fromWp.y, toWp.y],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing });
// Click pulse at waypoints marked click: true
let pulseOpacity = 0;
let pulseScale = 0;
for (const wp of waypoints) {
if (wp.click && frame >= wp.atFrame && frame < wp.atFrame + 20) {
const p = spring({ frame: frame - wp.atFrame, fps, config: { damping: 20, stiffness: 200 } });
pulseScale = interpolate(p, [0, 1], [0.5, 1.5]);
pulseOpacity = interpolate(frame - wp.atFrame, [0, 20], [0.5, 0], { extrapolateRight: 'clamp' });
}
}
return (
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 9999 }}>
{pulseOpacity > 0 && (
<div style={{
position: 'absolute', left: x - 15, top: y - 15,
width: 30, height: 30, borderRadius: '50%',
border: '2px solid rgba(255,255,255,0.8)',
transform: `scale(${pulseScale})`, opacity: pulseOpacity,
}} />
)}
<svg style={{ position: 'absolute', left: x, top: y, width: size, height: size,
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))' }} viewBox="0 0 24 24" fill="none">
<path d="M5 3L19 12L12 12L8 21L5 3Z" fill="white" stroke="#1e293b"
strokeWidth="1.5" strokeLinejoin="round" />
</svg>
</div>
);
};
10.3 — Root.tsx
import { Composition } from 'remotion';
import { DemoVideo } from './DemoVideo';
export const RemotionRoot = () => (
<Composition
id="AppDemo"
component={DemoVideo}
durationInFrames={300} // 10s at 30fps
fps={30}
width={1920}
height={1080}
/>
);
10.4 — 按应用类型选择演示模式
选择与你的应用匹配的模式,并根据需要组合使用。所有模式都通过 @app/ 导入实际应用组件。
模式 A:单视图滚动(仪表板、表格、长表单)
渲染组件,并通过动画调整 translateY 来滚动浏览内容。
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, Easing } from 'remotion';
import { Dashboard } from '@app/App';
import { sampleData } from '@app/sample-data';
import { AnimatedCursor } from './components/AnimatedCursor';
export const DemoVideo: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scrollY = interpolate(frame, [2 * fps, 6 * fps], [0, -500], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.quad),
});
return (
<AbsoluteFill style={{ backgroundColor: '#0f172a', overflow: 'hidden' }}>
<div style={{ transform: `translateY(${scrollY}px)`, width: '100%' }}>
<Dashboard data={sampleData} />
</div>
<AnimatedCursor waypoints={[
{ x: 700, y: 400, atFrame: 0 },
{ x: 700, y: 500, atFrame: Math.round(3 * fps) },
{ x: 500, y: 450, atFrame: Math.round(6 * fps) },
]} />
</AbsoluteFill>
);
};
模式 B:带标签页/多视图的应用(最常见)
使用 <Sequence> 切换视图。光标移至标签页/按钮,触发点击脉冲,然后下一个 Sequence 渲染新视图。
import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig, interpolate, Easing } from 'remotion';
import { Dashboard } from '@app/App';
import { sampleData } from '@app/sample-data';
import { AnimatedCursor } from './components/AnimatedCursor';
const OverviewScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scrollY = interpolate(frame, [2 * fps, 4 * fps], [0, -350], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.quad),
});
return (
<AbsoluteFill style={{ overflow: 'hidden' }}>
<div style={{ transform: `translateY(${scrollY}px)`, width: '100%' }}>
<Dashboard data={sampleData} activeTab="overview" />
</div>
<AnimatedCursor waypoints={[
{ x: 700, y: 400, atFrame: 0 },
{ x: 900, y: 450, atFrame: Math.round(3 * fps) },
// End near the target tab button
{ x: 1195, y: 48, atFrame: Math.round(4.5 * fps) },
]} />
</AbsoluteFill>
);
};
const DetailScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const scrollY = interpolate(frame, [2 * fps, 4 * fps], [0, -200], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.quad),
});
return (
<AbsoluteFill style={{ overflow: 'hidden' }}>
<div style={{ transform: `translateY(${scrollY}px)`, width: '100%' }}>
<Dashboard data={sampleData} activeTab="detail" selectedItem={sampleData.items[0]} />
</div>
<AnimatedCursor waypoints={[
{ x: 1195, y: 48, atFrame: 0, click: true }, // Click the tab
{ x: 600, y: 300, atFrame: Math.round(0.8 * fps) },
{ x: 500, y: 400, atFrame: Math.round(3 * fps) },
]} />
</AbsoluteFill>
);
};
export const DemoVideo: React.FC = () => {
const { fps } = useVideoConfig();
return (
<AbsoluteFill>
<Sequence from={0} durationInFrames={5 * fps} premountFor={fps}>
<OverviewScene />
</Sequence>
<Sequence from={5 * fps} durationInFrames={5 * fps} premountFor={fps}>
<DetailScene />
</Sequence>
</AbsoluteFill>
);
};
要点: 场景 1 中光标的最后一个路径点和场景 2 中的第一个路径点应位于同一位置(标签页按钮),以便在切换时营造无缝的“点击”效果。
模式 C:AI 聊天/对话类应用
使用基于帧的数组切片,逐步增加消息数量来渲染聊天组件。这样无需实际交互,就能展示消息“逐条到达”的效果。
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from 'remotion';
import { ChatPanel } from '@app/components/ChatPanel';
import { sampleMessages } from '@app/sample-data';
import { AnimatedCursor } from './components/AnimatedCursor';
// Pre-define which messages are visible at which frame
const messageTimeline = [
{ visibleCount: 2, atFrame: 0 }, // Start with 2 messages already visible
{ visibleCount: 3, atFrame: 60 }, // User message appears at 2s
{ visibleCount: 3, typingIndicator: true, atFrame: 90 }, // AI typing at 3s
{ visibleCount: 4, atFrame: 120 }, // AI response appears at 4s
{ visibleCount: 5, atFrame: 180 }, // Another user message at 6s
{ visibleCount: 5, typingIndicator: true, atFrame: 210 },
{ visibleCount: 6, atFrame: 240 }, // Final AI response at 8s
];
export const DemoVideo: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Find current state based on frame
let currentState = messageTimeline[0];
for (const state of messageTimeline) {
if (frame >= state.atFrame) currentState = state;
}
const visibleMessages = sampleMessages.slice(0, currentState.visibleCount);
return (
<AbsoluteFill style={{ overflow: 'hidden' }}>
<ChatPanel
messages={visibleMessages}
isTyping={currentState.typingIndicator ?? false}
/>
<AnimatedCursor waypoints={[
{ x: 960, y: 800, atFrame: 0 },
// Cursor moves to input area before "sending" a message
{ x: 700, y: 950, atFrame: 50, click: true },
{ x: 960, y: 600, atFrame: 90 }, // Watches AI type
{ x: 700, y: 950, atFrame: 170, click: true }, // Sends another
{ x: 960, y: 500, atFrame: 240 }, // Reads response
]} />
</AbsoluteFill>
);
};
聊天类应用要点: ChatPanel 组件必须将 messages 和 isTyping 作为 props 接收(阶段 7 会强制执行这一点)。阶段 3A 中的示例数据应包含一段逼真的对话。
模式 D:按钮/交互密集型应用(调度面板、表单)
按顺序渲染不同的 prop 状态,以模拟按钮点击。使用光标指向按钮,触发点击脉冲,然后切换到“点击后”状态。
// Scene 1: Empty form state, cursor fills in a field
<Sequence from={0} durationInFrames={4 * fps}>
<FormView formData={emptyForm} />
<AnimatedCursor waypoints={[
{ x: 500, y: 300, atFrame: 0 },
{ x: 500, y: 300, atFrame: 30, click: true },
]} />
</Sequence>
// Scene 2: Filled form, cursor clicks submit
<Sequence from={4 * fps} durationInFrames={3 * fps}>
<FormView formData={filledForm} />
<AnimatedCursor waypoints={[
{ x: 500, y: 600, atFrame: 0 },
{ x: 500, y: 600, atFrame: 30, click: true }, // Click "Submit"
]} />
</Sequence>
// Scene 3: Success state
<Sequence from={7 * fps} durationInFrames={3 * fps}>
<FormView formData={filledForm} submitSuccess={true} />
</Sequence>
10.5 — 光标路径点定位提示
- 查找坐标: 运行
npx remotion studio,将鼠标悬停在预览上,并记下按钮/标签页/元素渲染时的像素坐标。 - 在 Sequence 之间匹配: 当光标在场景 1 结束时“点击”某个标签页,其在场景 2 中的第一个位置应使用相同坐标,以实现无缝过渡。
- 移动速度: 每次移动控制在 30–60 帧(1–2 秒)。速度太快会显得慌乱,太慢则会让人觉得无聊。
- 不要过度添加动画: 每个场景移动光标 2–3 次就足够了。光标应该引导注意力,而不是分散注意力。
10.6 — 预览和渲染
cd demo
npx remotion studio # Preview in browser
npx remotion render AppDemo # Render to out/AppDemo.mp4
第 11 阶段 — 验证
发布后,确认:
- 应用在 Domo 中加载时没有控制台错误
- 所有数据集别名均可解析(数据请求没有 404 错误)
- AppDB 集合已接入卡片 UI(如果使用)
- 页面筛选器能正确传递(如果应用嵌入在仪表板中)
- 导航使用
domo.navigate(),而不是<a href> - 缩略图已复制到
public/文件夹 - 在 Domo 外运行时,示例数据能正确渲染(没有 undefined/null 错误)
- 演示视频渲染正常,没有空白帧或缺失资源(
npx remotion render AppDemo) - 演示视频显示已填充的 UI,而不是空状态或加载指示器
构建时防护规则
- 仅限客户端:不使用 SSR/服务器路由/服务器组件。
- 使用 Vite
base: './'。 - 除非已专门处理重写规则,否则优先使用
HashRouter。 - 仅将
domo.env.*视为便利功能;进行信任决策时使用经过验证的身份。 - 演示录制的任何组件都不得使用 CSS 过渡或动画。
- 示例数据不得包含任何真实客户数据。
- 所有演示视觉状态都必须由 props 驱动,而非由交互驱动。