关于 app-studio-pro-code
app-studio-pro-code 帮助在 Domo App Studio 页面中规划并嵌入 JavaScript 自定义应用卡片,适用于原生卡片无法满足需求的组件。它说明了页面布局、卡片实例创建,以及筛选器和变量与自定义应用的交互方式。
主要功能
- 01判断原生卡片或 Pro Code 的适用性
- 02规划页面组件与卡片布局
- 03构建并放置自定义应用卡片
- 04接收页面筛选器和变量变更
- 05读取及回写 App Studio 变量
- 06为可视化应用选择自定义调色板
适用场景
- 01制作甘特图、热力图等自定义可视化
- 02构建多步骤表单或复杂交互
- 03让页面筛选器驱动自定义应用数据
- 04通过变量控制或更新应用状态
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ stahura/ domo-ai-vibe-rules --skill app-studio-pro-code安装后在 Claude Code 中输入/app-studio-pro-code确认已加载。
附带文件references/app-studio-integration.mdreferences/design-skills.md
详细介绍
App Studio 中的专业代码自定义应用
需要自定义调色板:切勿使用 Domo 的原生/默认调色板。始终使用从
domo-app-theme/references/color-palettes.md中选取的项目精选调色板。在专业代码 CSS 中直接使用 OKLCH 值;为原生卡片覆盖设置转换为十六进制。所有图表系列、文本、网格和状态颜色都必须使用选定的调色板。
App Studio 的 Pro Code Editor 支持将完整的 JavaScript 自定义应用作为卡片嵌入 App Studio 画布。此技能衔接两种现有工作流:构建自定义应用(basic-custom-app-build)和在 App Studio 页面上放置卡片(basic-app-studio / advanced-app-studio)。
何时使用
当用户的需求超出原生 Domo 卡片的能力时,使用此技能。如果原生卡片可以胜任,则只使用 app-studio。
决策路由:原生卡片还是专业代码
根据下表评估用户需求中的每个组件。只要某个组件符合一项专业代码信号,该组件就应使用自定义应用。
| 需求 | 原生卡片 | 专业代码 |
|---|---|---|
| 标准图表(条形图、折线图、饼图、环形图、漏斗图) | X | |
| KPI/单值及比较 | X | |
| 支持排序的简单数据表 | X | |
| 下拉菜单/日期选择器筛选器 | X | |
| 自定义可视化(甘特图、热力图、组织结构图、日历、网络图) | X | |
| 多步骤表单或向导流程 | X | |
| 实时动画或过渡效果 | X | |
| 超出卡片规则范围的复杂条件格式 | X | |
| 跨卡片交互(一张卡片中的选择驱动另一张卡片) | X | |
| 使用自定义 UI 执行 AppDB 文档 CRUD | X | |
| AI 驱动的功能(聊天、生成、文本转 SQL) | X | |
| 超出 ca1–ca8 样式预设的自定义样式 | X | |
| 嵌入第三方库(D3、Mapbox、AG Grid) | X |
| 带渐变、文本和品牌元素的页眉/横幅图片 | | X | | 带有 Plan/Actual/Forecast 和置信区间的多系列时间图表 | | X |
单个 App Studio 页面可以混合使用原生卡片和专业代码卡片。请按组件而非按页面决定。
创建卡片实例:domo publish 会在 Asset Library 中创建一个设计。卡片实例通过两步 API 以编程方式创建——完整流程请参见 app-studio 中的“创建自定义应用卡片实例”部分(POST /domoapps/apps/v2/contexts → POST /domoapps/apps/v2)。每个卡片实例都有自己的上下文,并拥有独立的数据集映射。
构建工作流
第 1 步 — 规划页面
制定一份放置计划,列出目标页面上的每个组件:
Page: "Vendor Performance"
1. Region filter → native dropdown card
2. Date range filter → native date picker card
3. Revenue KPI → native KPI card
4. Cost KPI → native KPI card
5. Margin KPI → native KPI card
6. Revenue trend chart → native line chart card
7. Gantt timeline → PRO-CODE (custom Gantt, no native equivalent)
8. Vendor scorecard → PRO-CODE (interactive table with inline editing via AppDB)
第 2 步 — 构建原生卡片
使用 app-studio 创建并放置原生卡片(上方第 1–6 项)。遵循 app-studio 中适配设计的布局规则,安排间距、行协调和密度。
第 3 步 — 构建专业代码应用
对于每个专业代码组件,按照 basic-custom-app-build 操作手册执行,并预先加载以下技能栈:
始终加载:
- 从
domo-app-theme/references/color-palettes.md中选取的自定义调色板——选择适合使用场景的精选 OKLCH 调色板 make-interfaces-feel-better设计原则——请参见 design-skills.md
按需加载:
writing-better——当应用包含大量面向用户的文本(标签、工具提示、错误消息、空状态、帮助文本)时。
basic-custom-app-build 的各个阶段均适用:清单、应用外壳、数据访问、存储、工具包、功能技能、性能审查、构建、发布。
第 4 步 — 创建卡片实例并放置到 App Studio 页面
domo publish 成功后,使用两步 API 根据设计创建卡片实例。
重要:上下文/卡片创建端点使用根域名(https://{instance}.domo.com/domoapps/...),而不是 /api/ 前缀。使用 /api/domoapps/... 会返回 404 "No static resource"。
DOMO = f"https://{instance}.domo.com" # NOT {BASE} which has /api/
# Step 4a: Create a context for the custom app
ctx_body = {
"designId": design_id, # from manifest.json id field
"mapping": [
{"alias": alias, "dataSetId": ds_guid, "fields": [], "dql": None}
],
"collections": [],
"accountMapping": [], "actionMapping": [],
"workflowMapping": [], "packageMapping": [],
"isDisabled": False
}
resp = requests.post(f"{DOMO}/domoapps/apps/v2/contexts", headers=HEADERS, json=ctx_body)
context_id = resp.json()[0]["id"]
# Step 4b: Create the card on the target page
import urllib.parse
title = urllib.parse.quote("My Custom Chart")
resp = requests.post(
f"{DOMO}/domoapps/apps/v2?fullpage=false&pageId={page_id}&cardTitle={title}",
headers=HEADERS, json={"contextId": context_id, "id": context_id})
# Step 4c: Find the numeric card ID on the page
cards = requests.get(f"{BASE}/content/v1/pages/{page_id}/cards", headers=HEADERS).json()
card_id = next(c["id"] for c in cards if c["title"] == "My Custom Chart")
对于没有数据集的应用(横幅),使用 "mapping": []。针对特定页面的横幅,应为每个页面分别创建卡片实例。
卡片会自动添加到页面。通过更新布局模板,将其从附录移到画布(与原生卡片的流程相同——参见 app-studio)。
第 5 步 — 配置专业代码卡片的布局
专业代码卡片通常需要比原生卡片更大的画布空间。建议尺寸:
| 专业代码应用类型 | 宽度 | 高度 | 备注 |
|---|---|---|---|
| 全宽交互式(甘特图、时间线、地图) | 60 | 30–40 | 占满整行 |
| 半宽可视化(自定义图表、记分卡) | 30 | 22–30 | 可与另一张卡片并排 |
| 紧凑型小组件(状态指示器、迷你表单) | 20 | 15–20 | 一行可放三个 |
| 高型交互式(聊天、多步表单) | 20–30 | 35–45 | 需要垂直空间 |
为专业代码卡片设置内容条目属性:
{
"hideTitle": true,
"hideDescription": true,
"hideFooter": true,
"hideBorder": false,
"hideMargins": false,
"fitToFrame": true,
"acceptFilters": true,
"acceptDateFilter": true,
"acceptSegments": true
}
专业代码卡片通常会设置 hideTitle: true 和 hideFooter: true,因为自定义应用会自行渲染页眉和页脚。fitToFrame: true 会缩放应用,使其填满网格单元格。
App Studio 集成:筛选器和变量
嵌入 App Studio 的专业代码卡片会接收两类外部输入:页面级筛选器(来自原生筛选器卡片)和变量(来自 App Studio 变量控件)。这两类输入都需要在顶层注册显式监听器。有关详细模式和生产环境示例,请参阅 app-studio-integration.md。
页面级筛选器
在顶层(任何组件生命周期或 useEffect 之外)注册 domo.onFiltersUpdated(过去时)。用户更改同一 App Studio 页面上的原生筛选器卡片时,此监听器会触发。
domo.onFiltersUpdated((filters) => {
if (!Array.isArray(filters) || filters.length === 0) return;
filters.forEach((filter) => {
const { column, operand, values, dataType } = filter;
// Map column to internal state, then refetch data
});
fetchData();
});
筛选器对象结构——数组中的每个元素:
| 字段 | 类型 | 描述 |
|---|---|---|
column | 字符串 | 数据集列名(例如,"YEAR_OF_EVENT") |
operand | 字符串 | 不是 operator。可选值:BETWEEN、IN、GREATER_THAN_EQUAL、LESS_THAN_EQUAL、EQUALS |
values | 数组 | 筛选值。对于 BETWEEN:包含 2 个元素 [min, max]。对于 IN:包含所选值的数组。对于标量操作数:包含 1 个元素。 |
dataType | 字符串 | 列的数据类型(例如,"LONG"、"STRING"、"DATE") |
操作数处理模式:
| 操作数 | 值 | 映射方式 |
|---|---|---|
BETWEEN | [min, max] | 范围筛选器:{ min: Number(values[0]), max: Number(values[1]) } |
IN | ["val1", "val2"] | 分类值:存储为数组。空数组表示“全部”(不筛选)。 |
GREATER_THAN_EQUAL | [threshold] | 更新范围的 .min |
LESS_THAN_EQUAL | [threshold] | 更新范围的 .max |
列名兼容性:Domo 列名可能以不同格式传入(例如,YEAR_OF_EVENT、YEAROFEVENT、Year of Event)。使用 column?.toUpperCase() 规范化,并在 switch/case 中处理常见变体。
要使其正常工作:
- 内容条目必须设置
acceptFilters: true - 自定义应用必须使用与筛选器目标相同的数据集
- 必须同时在 App Studio 页面的卡片配置和自定义应用的
manifest.json中映射该数据集 - 处理筛选器后,立即触发数据重新获取——页面筛选器会在更改时应用,而不是在显式“应用”操作时应用
App Studio 变量
通过 API 创建变量
Pro-code 应用要使用变量,必须先创建变量。通过函数模板 API 创建变量时,将 variable: true:
resp = requests.post(
f"{BASE}/query/v1/functions/template?strict=false",
headers={"X-Domo-Authentication": SID, "Content-Type": "application/json"},
json={
"name": "Selected Metric",
"locked": False,
"global": True,
"expression": "'Revenue'", # Default value as SQL literal
"links": [],
"aggregated": False,
"analytic": False,
"nonAggregatedColumns": [],
"dataType": "STRING", # STRING, DECIMAL, LONG, DATE
"cacheWindow": "non_dynamic",
"columnPositions": [],
"functions": [],
"functionTemplateDependencies": [],
"archived": False,
"hidden": False,
"variable": True, # CRITICAL — makes this a Variable
},
timeout=15,
)
data = resp.json()
function_id = data["id"] # e.g., 115511 — use this numeric ID in pro-code
返回的 id 是数字类型的 functionId,pro-code 应用会在 domo.onVariablesUpdated 和 domo.requestVariablesUpdate 中使用它。请保存这些 ID——它们是 API 创建的变量与 pro-code 应用之间的桥梁。
必须在 App Studio 编辑器中手动添加变量控件——目前没有可用的 API。创建变量后,请告知用户在编辑器中进行绑定:打开应用 → 点击左侧工具栏中的 Controls 图标 → 将控件拖到画布上 → 选择要绑定的变量。完整的 UI 操作说明请参阅 app-studio 技能。
在 Pro-Code 中使用变量
在顶层注册 domo.onVariablesUpdated(过去时)。每当任何 App Studio 变量控件的值发生变化时,它都会触发。变量由数字字符串 ID标识(例如,"858"、"860")。
domo.onVariablesUpdated((variables) => {
if (!variables || typeof variables !== "object") return;
const typeVar = variables["860"];
if (typeVar?.parsedExpression?.value) {
const label = typeVar.parsedExpression.value;
const mapped = LABEL_TO_VALUE_MAP[label];
if (mapped) state.pendingValues.someField = { label, value: mapped };
}
updateUI();
});
变量对象结构——variables 是一个以变量的数字型函数 ID 为键的对象:
{
"858": { "parsedExpression": { "exprType": "LITERAL", "value": "Year Sold" } },
"860": { "parsedExpression": { "exprType": "LITERAL", "value": "Cumulative" } }
}
暂存/提交模式:变量会在每次按键或选择时触发。请采用两阶段处理方式:
onVariablesUpdated将值存入pendingValues(暂存区)- 用户点击 Apply 按钮,将暂存值提交到活动状态
- Apply 触发数据重新获取
这样可以避免用户调整多个变量控件时频繁重新获取数据。
标签到值的映射:变量值以显示标签的形式传入(例如,"Days"、"Cumulative")。自定义应用必须使用查找表将这些值映射到内部枚举值:
const RETENTION_TYPE_MAP = {
"Cumulative": "CUMULATIVE",
"Marginal": "ADJUSTED_MARGINAL_RETENTION",
"Lifetime": "LIFETIME",
};
写回变量
Pro-code 应用可以使用 domo.requestVariablesUpdate 更新 App Studio 变量。这样就能实现双向通信——例如,选择事件类型时,自动更新相关的事件子类型变量。
domo.requestVariablesUpdate(
[{ functionId: 873, value: "Initial Service" }],
(ackResponse) => { /* acknowledged */ },
(replyResponse) => { /* completed */ }
);
防止循环:更新变量会再次触发 onVariablesUpdated。请使用标志进行保护:
state.isUpdatingVariable = true;
domo.requestVariablesUpdate(updates, onAck, (reply) => {
state.isUpdatingVariable = false;
});
// In the onVariablesUpdated handler:
if (state.isUpdatingVariable) return;
集成摘要
| 机制 | API | 触发时机 | 重新获取策略 |
|---|---|---|---|
| 页面筛选器 | domo.onFiltersUpdated | 原生筛选器卡片发生变化 | 立即——每次筛选器变化时都重新获取 |
| 变量 | domo.onVariablesUpdated | 变量控件发生变化 | 暂存——在 Apply 时提交暂存值 |
| 变量写回 | domo.requestVariablesUpdate | 应用需要更新变量时 | 使用 isUpdatingVariable 标志进行保护 |
主题对齐
Pro-code 卡片在带有独立 CSS 的 iframe 中渲染——不会继承 App Studio 应用的主题。为了保持视觉一致性:
- 绝不使用 Domo 的原生调色板。 始终使用从
domo-app-theme/references/color-palettes.md中选取的精选调色板。直接在 CSS 自定义属性中使用 OKLCH 值,或将其转换为十六进制值,用于内联 JS 颜色引用。所有图表系列、文本、网格线和状态颜色都必须与 App Studio 主题的自定义调色板一致。Pro-code 调色板映射示例:- 系列 1(主色):自定义调色板中的 brand-500
- 系列 2:自定义调色板中的次要 ORDER_SOURCE(c22)
- 系列 3:自定义调色板中的第三 ORDER_SOURCE(c29)
- 系列 4:自定义调色板中的第四 ORDER_SOURCE(c36)
- 网格线:neutral-200
- 主文本:neutral-950
- 次要文本:neutral-500
- 成功:success-600 | 错误:error-600
- 匹配页面背景:将自定义应用的 body 背景设为
transparent,使其与周围画布融为一体。 - 匹配卡片样式:Pro-code 容器应设置
background: transparent、不使用box-shadow,且不设置border-radius。App Studio 卡片框架会提供所有外观元素。 - Pro-code 容器不得使用投影。 卡片样式(ca1,带 1px 边框且无阴影)负责呈现卡片边缘。在 iframe 内添加阴影会形成双重边框效果。
专业代码应用的调色板工作流
构建专业代码应用时,必须使用与 App Studio 主题相同的精选调色板。调色板来源是项目的 DESIGN.md(位于 domo-app-theme/references/themes/)。专业代码应用应通过 CSS 自定义属性使用 OKLCH 值,或将其转换为十六进制值,以供内联 JS 引用。在每个专业代码应用的 app.js 顶部定义一个 COLORS 对象,其中包含项目调色板的值。
关键 — 主题颜色继承:专业代码组件在 iframe 中渲染,不会继承 App Studio 主题颜色。专业代码 CSS 和 JS 中使用的每种颜色都必须明确取自与 App Studio 主题相同的 DESIGN.md。这包括:
- 横幅渐变背景、强调色叠加层、品牌文字颜色、顶部边框
- 图表主色(P)、网格线、坐标轴文字、工具提示背景
- 多系列图表的线条/条形颜色(C1、C2、C3……)
- 所有文字颜色(标题、副标题、标签、图例)
如果主题调色板发生变化(例如从绿色改为铜色),必须更新并重新发布每个专业代码组件,使用新颜色。这里没有自动继承机制。若忘记更新,原生 Domo 卡片(遵循主题)与专业代码卡片(使用硬编码值)之间就会出现突兀的不协调。
时间轴刻度密度
构建跨越数月/数年的日数据时间序列图表时,原始的每日刻度会相互重叠,难以阅读。请使用“每隔 N 天”间隔策略:
const calcTickInterval = (dataLength) => {
if (dataLength <= 30) return 0; // show every tick
if (dataLength <= 90) return 6; // ~weekly
if (dataLength <= 180) return 13; // ~biweekly
return Math.ceil(dataLength / 18) - 1; // ~18 visible ticks
};
// In XAxis:
// interval={aggregation === 'day' ? calcTickInterval(data.length) : 'preserveStartEnd'}
目标是在任何视口宽度下显示约 15–20 个刻度标签。对于倾斜标签(数据密集时),使用 angle: -35 和 textAnchor: 'end'。
专业代码卡片阴影与边框处理
嵌入 App Studio 的专业代码卡片不应在最外层容器上渲染自己的 box-shadow 或 border-radius。App Studio 卡片框架(通过内容条目中的 style.sourceId)负责处理卡片外观。将自定义应用的容器设置为 background: transparent; border-radius: 0; padding: 20px 24px 16px;,由卡片样式提供外观。
必须使用零圆角——所有卡片样式的 borderRadius 都为 0,因此专业代码容器也必须使用 border-radius: 0。任何地方都不能有圆角。
响应式行为
App Studio 通过 standard 和 compact 模板,以两个断点渲染专业代码卡片。自定义应用自身的响应式 CSS 则独立运行于其 iframe 内。
对于 compact(移动端)模板:
- 将专业代码卡片的宽度设为 12(移动端全宽)
- 高度应高于桌面版本(内容会纵向堆叠)
- 自定义应用内部的响应式断点应适配较窄的视口
设计技能集成
有关如何为专业代码应用提升设计质感的详细指南,请参阅 design-skills.md。
完整工作流示例
构建一个包含 4 个原生 KPI 卡片和 1 个专业代码甘特图的 App Studio 页面:
import requests
BASE = "https://instance.domo.com/api"
HEADERS = {"X-DOMO-Developer-Token": TOKEN, "Content-Type": "application/json"}
# 1. Create the App Studio app
app = requests.post(f"{BASE}/content/v1/dataapps",
headers=HEADERS, json={"title": "Project Tracker"}).json()
page_id = app["landingViewId"]
# 2. Create 4 native KPI cards on the page
kpi_ids = []
for card_def in kpi_definitions:
resp = requests.put(f"{BASE}/content/v3/cards/kpi?pageId={page_id}",
headers=HEADERS, json=card_def)
kpi_ids.append(resp.json()["id"])
# 3. Build and publish the Gantt custom app (via basic-custom-app-build skill)
# ... scaffold, code, npm run build, cd dist, domo publish ...
gantt_design_id = "abc12345-..." # from manifest.json id field
# 4. Create a card instance from the published design
# NOTE: Use root domain (DOMO), NOT {BASE} which has /api/ prefix
DOMO = f"https://{instance}.domo.com"
import urllib.parse
ctx = requests.post(f"{DOMO}/domoapps/apps/v2/contexts", headers=HEADERS, json={
"designId": gantt_design_id,
"mapping": [{"alias": "tasks", "dataSetId": dataset_id, "fields": [], "dql": None}],
"collections": [], "accountMapping": [], "actionMapping": [],
"workflowMapping": [], "packageMapping": [], "isDisabled": False
}).json()[0]
context_id = ctx["id"]
requests.post(
f"{DOMO}/domoapps/apps/v2?fullpage=false&pageId={page_id}&cardTitle={urllib.parse.quote('Project Gantt')}",
headers=HEADERS, json={"contextId": context_id, "id": context_id})
gantt_card_id = next(c["id"] for c in requests.get(
f"{BASE}/content/v1/pages/{page_id}/cards", headers=HEADERS).json()
if "Gantt" in c.get("title", ""))
# 5. Get and update the layout
layout = requests.get(f"{BASE}/content/v4/pages/{page_id}/layouts",
headers=HEADERS).json()
layout_id = layout["layoutId"]
# Acquire write lock
requests.put(f"{BASE}/content/v4/pages/layouts/{layout_id}/writelock",
headers=HEADERS, json={})
# Build template: KPIs on row 1, Gantt below
# (Read actual contentKeys from layout["content"] first)
std_template = [
{"type": "SPACER", "contentKey": 0, "x": 0, "y": 0,
"width": 60, "height": 3, "virtualAppendix": False,
"virtual": False, "children": None},
# ... KPI cards at y=3, width 15 each, height 10 ...
# ... SPACER at y=13, height 3 ...
# ... Gantt card at y=16, width 60, height 35 ...
]
# Set Gantt card content properties
for c in layout["content"]:
if c.get("cardId") == gantt_card_id:
c["hideTitle"] = True
c["hideFooter"] = True
c["fitToFrame"] = True
layout["standard"]["template"] = std_template
# ... build compact template similarly ...
requests.put(f"{BASE}/content/v4/pages/layouts/{layout_id}",
headers=HEADERS, json=layout)
requests.delete(f"{BASE}/content/v4/pages/layouts/{layout_id}/writelock",
headers=HEADERS)
已验证行为
根据在 App Studio 中运行的生产级专业代码应用:
- 筛选器传播正常:当同一页面上的原生筛选器卡片发生变化时,专业代码卡片中的
domo.onFiltersUpdated会触发。内容条目需要设置acceptFilters: true。筛选器对象使用operand(而非operator)。 - 变量传播正常:当 App Studio 变量控件发生变化时,
domo.onVariablesUpdated会触发。变量通过数字函数 ID 识别。domo.requestVariablesUpdate支持将变量写回。 - 主题隔离已确认:专业代码卡片在隔离的 iframe 中渲染。App Studio 中没有 CSS 会被继承。自定义应用必须自行提供所有样式。
domo.get会隐式遵循页面筛选器:使用domo.get("/data/v1/alias")时,平台会自动将当前有效的页面筛选器应用到数据请求。不过,这只适用于 Data API v1 查询;Code Engine 调用、SQL 查询和其他数据访问方式都需要通过onFiltersUpdated明确处理筛选器。
专业代码模式:CSS 渐变横幅
一个轻量级自定义应用,用于呈现品牌化页面页眉。无需 JS 框架或数据集。
文件:index.html、app.css、manifest.json
<div class="banner">
<div class="banner-geo"></div>
<div class="banner-content">
<span class="banner-brand">COMPANY NAME</span>
<h1 class="banner-title" id="bannerTitle">PAGE TITLE</h1>
<p class="banner-sub" id="bannerSub">Contextual subheader describing this page's focus</p>
</div>
</div>
/* app.css — dark gradient with subtle background pattern (see Banner Background Patterns section) */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
.banner {
width: 100%; height: 100%;
background:
radial-gradient(circle, rgba(91, 153, 213, 0.07) 1px, transparent 1px), /* dot grid pattern */
linear-gradient(135deg, #23272E 0%, #3A4352 100%);
background-size: 20px 20px, 100% 100%;
display: flex; align-items: center;
}
.banner-content { padding: 0 40px; }
.banner-brand { font-size: 11px; font-weight: 600; letter-spacing: 0.14em; color: #5B99D5; display: block; margin-bottom: 6px; } /* brand-400 */
.banner-title { font-size: 26px; font-weight: 600; color: #FFFFFF; letter-spacing: 0.02em; margin-bottom: 4px; }
.banner-sub { font-size: 13px; font-weight: 400; color: #B1BAC8; max-width: 600px; line-height: 1.4; } /* neutral-300 */
// manifest.json — no datasets
{"name": "Banner", "version": "1.0.0", "size": {"width": 10, "height": 1}, "mapping": []}
放置位置:宽度 60,高度 14,y=0。内容条目:hideTitle、hideFooter、hideBorder、hideMargins、hideWrench 均为 true,fitToFrame: true,样式为 ca8。较大的高度(14,而不是 7)可容纳副标题文本,并营造更有意图的视觉焦点。
每页标题:为每个页面创建单独的横幅设计(例如 mfg-banner-overview/、mfg-banner-production/),并在各自的 index.html 中硬编码标题和副标题。不要在多个页面间共用同一个设计——iframe 卡片无法从 App Studio 宿主接收 URL 参数。分别发布每个设计,然后通过上下文 API 在相应页面上为每个设计创建一个卡片实例。
颜色调色板一致性:在所有专业代码组件中使用 domo-app-theme/references/color-palettes.md 中精选的调色板。切勿使用 Domo 原生/默认颜色。图表系列颜色应与 App Studio 主题的 ORDER_SOURCE 值(c8、c22、c29、c36)匹配。
横幅背景图案
横幅应在基底渐变上叠加细腻的背景图案。图案能增加视觉层次和高级的编辑风格,同时不干扰文本内容。图案使用纯 CSS(无需外部图像),以极低的不透明度使用强调色,并适配调色板。
始终添加图案。 根据数据领域为每个应用选择一种图案(所有页面使用相同图案以保持一致性)。将 .banner 中的纯色 linear-gradient 替换为分层的 background 属性,以应用图案。
| 图案 | 适用场景 | 视觉风格 |
|---|---|---|
| 点阵网格 | 企业、分析、SaaS | 简洁、数据导向、结构化 |
| 斜线 | 制造、工程、能源 | 动感、精准、工业风 |
| 径向辉光 | 高管、概览、主视觉页面 | 聚光、聚焦、高级感 |
| 地形图 | 供应链、物流、地理 | 技术感、层次感、复杂性 |
| 交叉影线 | 金融、合规、医疗保健 | 编织感、纹理感、机构风格 |
点阵网格
以强调色绘制的小型重复圆点。细腻、规整,适用于各种场景。
.banner {
background:
radial-gradient(circle, var(--accent-dot) 1px, transparent 1px),
linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
background-size: 20px 20px, 100% 100%;
}
浅色模式示例:--accent-dot: rgba(58, 105, 131, 0.07)
深色模式示例:--accent-dot: rgba(232, 122, 32, 0.08)
斜线
细窄的倾斜线条,体现动感与精准。
.banner {
background:
repeating-linear-gradient(
-45deg,
transparent,
transparent 40px,
var(--line-color) 40px,
var(--line-color) 41px
),
linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
}
浅色模式:--line-color: rgba(58, 105, 131, 0.04)
深色模式:--line-color: rgba(232, 122, 32, 0.05)
径向辉光
文本区域背后的柔和聚光效果,营造视觉焦点和高级层次感。
.banner {
background:
radial-gradient(ellipse at 30% 50%, var(--glow-color) 0%, transparent 60%),
linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
}
浅色模式:--glow-color: rgba(58, 105, 131, 0.08)
深色模式:--glow-color: rgba(232, 122, 32, 0.12)
地形图
唤起等高线地图意象的同心圆环,具有技术感和层次感。
.banner {
background:
radial-gradient(ellipse at 75% 40%, transparent 30%, var(--topo-line) 30.5%, transparent 31%),
radial-gradient(ellipse at 75% 40%, transparent 45%, var(--topo-line) 45.5%, transparent 46%),
radial-gradient(ellipse at 75% 40%, transparent 60%, var(--topo-line) 60.5%, transparent 61%),
radial-gradient(ellipse at 75% 40%, transparent 75%, var(--topo-line) 75.5%, transparent 76%),
linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
}
浅色模式:--topo-line: rgba(58, 105, 131, 0.05)
深色模式:--topo-line: rgba(232, 122, 32, 0.06)
交叉影线
精细的编织网格图案,具有机构风格和纹理感。
.banner {
background:
linear-gradient(0deg, var(--hatch-color) 1px, transparent 1px),
linear-gradient(90deg, var(--hatch-color) 1px, transparent 1px),
linear-gradient(135deg, var(--bg-start) 0%, var(--bg-end) 100%);
background-size: 24px 24px, 24px 24px, 100% 100%;
}
浅色模式:--hatch-color: rgba(58, 105, 131, 0.03)
深色模式:--hatch-color: rgba(232, 122, 32, 0.04)
实现说明
- 不透明度范围:将图案元素的不透明度控制在 0.03–0.12 之间。更高的不透明度会与文本争夺视觉焦点。
- CSS 自定义属性:示例使用
var(--...)占位符。在实际的横幅app.css中,请替换为项目调色板中的具体颜色值(例如,Charcoal Ember 强调色可使用rgba(232, 122, 32, 0.08))。 - 一致性:同一应用中的所有页面横幅都使用相同图案。每页使用不同图案会显得杂乱。
- 文本可读性:横幅文本区域(横幅左侧 40%)应使用最强的渐变效果。将径向/焦点图案放在横幅右侧的无文本区域。
- 性能:纯 CSS 渐变不会产生加载开销。横幅图案绝不能使用
<canvas>、JavaScript 粒子系统或外部图像文件。
深色模式专业代码应用:使用深色模式调色板(domo-app-theme/references/color-palettes.md 中的 D1 Emerald、D2 Neon Magenta、D3 Charcoal Ember)时,专业代码 CSS 必须明确设置浅色文本、深色容器背景和适配深色模式的网格/坐标轴颜色。专业代码应用通过显式 CSS 正确处理深色模式——关键故障点在于 App Studio 原生主题。将深色背景颜色应用到主题后,必须将卡片、导航、页眉和组件中的所有 c60 字体颜色引用替换为 c58。否则,原生元素(主视觉卡片、筛选器下拉菜单、导航文本、分区标题)会以深色文字显示在深色背景上而无法辨认。必须参阅 app-studio 技能中的“深色模式主题”部分并进行修复。
专业代码图表模式(仅在明确要求时使用)
**专业代码图表为可选项。仅当用户明确要求专业代码、自定义图表、Recharts、D3 或自定义可视化时,才构建这类图表。**默认构建应使用 Domo 原生图表。
明确要求专业代码时,请使用以下经过验证且在生产环境中测试过的模式。下方所有参考应用都已部署,并确认在 Domo App Studio iframe 中正常运行。
默认库选择
需要专业代码图表时,始终默认使用以下两种技术栈之一——除非用户明确要求,否则不要引入其他图表库:
| 技术栈 | 使用场景 | 参考项目 |
|---|---|---|
| React + Recharts(模式 1) | 大多数图表的默认选择。适用于需要 React 状态、组合或复杂交互的图表。 | forecast line recharts/ |
| Chart.js vanilla(模式 3) | 适用于较简单的图表、使用 React 会带来不必要开销的情况,或应用已使用原生 JavaScript(例如 Code Engine/AI 集成)的情况。 | temp-apt-vanilla/customer-retention-aptive/src/vanilla/ |
如果用户没有指定偏好,默认使用模式 1(React + Recharts)。
已验证的参考模式 1:React + Recharts + Import Maps(默认)
多个生产应用都采用此模式。关键架构:
index.html通过<script src="https://unpkg.com/ryuu.js"></script>显式加载ryuu.js- Import maps 从
esm.sh加载 React 18.2.0 和 Recharts 2.12.7(无需 npm 构建步骤) app.js是使用React.createElement()的 ES 模块(无需 JSX 转译)- 使用
domo.get('/data/v1/{alias}')获取数据,并配合try/catch - 筛选器监听器:如果可用(较新版本的
ryuu.js),使用domo.onFiltersUpdate;对于较旧版本,则回退到domo.onFiltersUpdated。始终将监听器注册包装在try { ... } catch (_) {}中,以处理初始化边缘情况。 - TODO:待
ryuu.js在受支持版本间的监听器命名稳定后,将所有技能/示例统一为一个兼容性辅助函数。
可运行示例(复制并根据需要调整;这些示例已验证可运行):
_archive_prior_build/mfg-production-chart/— 多折线图,包含 Actual/Plan/Forecast 和置信区间,以及聚合控制项(Daily/Weekly/Monthly)_archive_prior_build/mfg-quality-chart/— 双折线图(缺陷率 + 废品率),带面积填充,按周聚合forecast line recharts/— 带 MAE 置信区间的访问量预测图表
已验证的参考模式 2:D3 + Observable Plot(原生 JavaScript)
适用于更复杂的可视化(蜂群图、力导向布局、自定义 SVG):
- 通过 CDN 使用
d3.min.js和@observablehq/plot - 使用原生
<script>标签(无需 ES 模块/import maps) - 通过
@domoinc/query辅助函数调用domo.get() - 可运行示例:
_archive_prior_build/ddx-snowflake_/— 带详情面板的 D3 力导向蜂群图
已验证的参考模式 3:Chart.js(原生 JS)
适用于较简单的图表,或不需要 React 的场景。这是默认的原生(非 React)技术栈。
- 使用 jsDelivr CDN 上的 Chart.js 4.4.1
- 原生 JavaScript,不使用框架
- 从 unpkg 加载
ryuu.js(与模式 1 相同) - 可运行示例:
temp-apt-vanilla/customer-retention-aptive/src/vanilla/——使用 Code Engine + AI 集成的客户留存图表
CDN URL(固定使用这些确切版本):
<script src="https://unpkg.com/ryuu.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
仅在特定功能需要时添加其他 CDN 库(例如,用于 AI Markdown 渲染的 marked、用于解压缩的 pako)。上面的两个脚本是原生技术栈的基础。
Chart.js 原生样式参考(来自 customer-retention-aptive):
// Chart.js configuration matching the reference app
const chart = new Chart(ctx, {
type: 'line',
data: { datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'nearest', intersect: false },
scales: {
x: {
type: 'linear',
title: { display: true, text: 'X Label', color: tickColor, font: { size: 12, weight: 500 } },
grid: { color: gridColor, drawBorder: false },
ticks: { color: tickColor, font: { size: 11 }, callback: v => v.toLocaleString() }
},
y: {
min: 0, max: 100,
title: { display: true, text: 'Percent (%)', color: tickColor, font: { size: 12, weight: 500 } },
grid: { color: gridColor, drawBorder: false },
ticks: { color: tickColor, font: { size: 11 }, callback: v => `${v}%` }
}
},
plugins: {
legend: {
position: 'right',
labels: { color: legendColor, font: { size: 11 }, usePointStyle: true, pointStyle: 'line' }
},
tooltip: { enabled: false, external: externalTooltipHandler }
}
}
});
各数据集的线条配置:
// Line style options — match reference app patterns
const getLineConfig = (color) => ({
tension: 0.4,
borderWidth: 2.5,
borderColor: color,
backgroundColor: color,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverBackgroundColor: color,
pointHoverBorderColor: '#e8ebe8', // or '#344D38' for dark theme
pointHoverBorderWidth: 2,
});
字体系列:必须与项目 DESIGN.md 中设置的 App Studio 主题字体系列一致。映射如下:
| 主题字体系列 | 专业代码 CSS font-family |
|---|---|
| 无衬线 | -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif |
| 衬线 | Georgia, "Times New Roman", "Palatino Linotype", serif |
| 板衬线 | "Roboto Slab", "Rockwell", "Courier New", serif |
绝不要硬编码与 App Studio 主题不匹配的字体栈。 如果主题使用衬线字体,所有专业代码组件也必须使用衬线字体。原生卡片与专业代码卡片的字体不匹配会破坏视觉协调性。
容器布局:.app-container 的 height: 100vh,.chart-container 的 flex: 1; min-height: 0; padding: 16px。Canvas 设置为 width: 100% !important; height: 100% !important。
重要:每个子页面都必须使用不同的图表类型。 不要在所有页面重复使用折线图。概览页面使用时间序列折线图/面积图。子页面必须有所变化:使用柱状图、堆叠柱状图、水平柱状图、散点图、蜂群图、热力图或树状图。根据页面数据形态选择图表类型:
| 页面数据形态 | 图表类型 | Recharts 组件 |
|---|---|---|
| 随时间变化的趋势 | 折线图 / 面积图 | ComposedChart + Line / Area |
| 类别对比 | 垂直柱状图 | BarChart + Bar |
| 构成 / 明细 | 堆叠柱状图 | BarChart + Bar(堆叠) |
| 排名 / 分布 | 水平柱状图 | BarChart layout="vertical" + Bar |
| 相关性 / 密度 | 散点图 | ScatterChart + Scatter |
| 部分与整体 | 圆环图 / 饼图 | PieChart + Pie |
这是一个静态 HTML 应用(无需构建步骤)。index.html 会显式加载 ryuu.js,并使用浏览器原生导入映射从 esm.sh 加载 React 和 Recharts。请在应用目录中直接使用 domo publish 发布。
文件:index.html、app.css、app.js、manifest.json、thumbnail.png
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="app.css" />
</head>
<body>
<div id="app"></div>
<script src="https://unpkg.com/ryuu.js"></script>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/[email protected]",
"react-dom/client": "https://esm.sh/[email protected]/client",
"recharts": "https://esm.sh/[email protected][email protected],[email protected]"
}
}
</script>
<script type="module" src="app.js"></script>
</body>
</html>
/* app.css — styling matched to forecast-line-recharts reference app */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden;
background: #F8F8F9;
color: #1F2937;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
#app { width: 100%; height: 100%; display: flex; flex-direction: column; padding: 12px; }
.chart-container { display: flex; flex-direction: column; flex: 1; min-height: 0;
background: #F1F1F3; padding: 16px; }
.chart-header { margin-bottom: 12px; display: flex; justify-content: space-between;
align-items: flex-start; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
.chart-title-section h1 { font-size: 16px; font-weight: 600; color: #111827; margin-bottom: 2px; }
.chart-subtitle { font-size: 12px; color: #6B7280; }
.chart-wrapper { flex: 1; min-height: 0; background: #F1F1F3; padding: 8px; }
/* Dropdown — matches reference .aggregation-select exactly */
.aggregation-select {
appearance: none; background: #FFFFFF;
border: 1px solid #D1D5DB; border-radius: 3px;
padding: 3px 18px 3px 6px; font-size: 10px;
color: #374151; cursor: pointer; min-width: 50px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 12 12'%3E%3Cpath d='M3 5l3 3 3-3' fill='none' stroke='%236B7280' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 4px center;
}
.aggregation-select:hover { border-color: #9CA3AF; }
.aggregation-select:focus { outline: none; border-color: #3A6983;
box-shadow: 0 0 0 2px rgba(58, 105, 131, 0.1); }
/* Confidence band toggle — matches reference .confidence-toggle */
.confidence-toggle {
display: flex; align-items: center; gap: 3px;
background: #FFFFFF; border: 1px solid #D1D5DB; border-radius: 3px;
padding: 3px 6px; font-size: 10px; color: #6B7280;
cursor: pointer; transition: all 0.15s ease;
}
.confidence-toggle:hover { border-color: #99A9BD; }
.confidence-toggle.active { background: #CAE1F0; border-color: #5E92CE; color: #3A6983; }
.toggle-icon { font-size: 8px; }
/* Tooltip — matches reference .custom-tooltip */
.custom-tooltip {
background: #FFFFFF; border: 1px solid #E5E7EB; border-radius: 6px;
padding: 12px 14px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); font-size: 13px;
}
.tooltip-date { font-weight: 600; color: #111827; margin-bottom: 8px;
padding-bottom: 8px; border-bottom: 1px solid #F3F4F6; }
.custom-tooltip p { margin: 4px 0; }
/* Legend footer — matches reference .chart-footer */
.chart-footer { display: flex; justify-content: center; gap: 16px; margin-top: 8px;
padding-top: 8px; border-top: 1px solid #F3F4F6; flex-wrap: wrap; flex-shrink: 0; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #4B5563; }
.legend-line { width: 24px; height: 2px; border-radius: 1px; }
.legend-line.dashed { background: repeating-linear-gradient(
to right, currentColor 0, currentColor 4px, transparent 4px, transparent 7px); }
/* Loading state — matches reference .loading-container */
.loading-container { display: flex; flex-direction: column; align-items: center;
justify-content: center; flex: 1; color: #6B7280; font-size: 12px; }
.loading-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0;
width: 24px; height: 24px; margin-bottom: 8px; }
.loading-cell { background: #5E92CE; border-radius: 1px;
animation: gridPulse 1.2s ease-in-out infinite; }
@keyframes gridPulse {
0%, 100% { opacity: 0.3; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1); }
}
/* Error state — matches reference */
.error-container { display: flex; flex-direction: column; align-items: center;
justify-content: center; flex: 1; text-align: center; padding: 24px; }
.error-container h2 { color: #DC2626; font-size: 18px; margin-bottom: 8px; }
.error-container p { color: #6B7280; font-size: 14px; margin-bottom: 4px; }
/* Grid line override — reference uses lighter horizontal grid */
.recharts-cartesian-grid-horizontal line { stroke: #F3F4F6; }
.recharts-legend-wrapper { display: none !important; }
@media (max-width: 768px) {
#app { padding: 16px; }
.legend-item { font-size: 12px; }
}
/* app.js — PRODUCTION-GRADE React + Recharts chart (ES module)
Styling matched to forecast-line-recharts reference app.
Copy and customize COLORS, ALIAS, column names, TITLE, Y_LABEL per page. */
import React, { useState, useEffect, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
import {
ComposedChart, Area, Line, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine
} from 'recharts';
// ── CUSTOMIZE THESE PER PAGE ──────────────────────────────────────────
const COLORS = {
primary: '#3A6983', // main series — replace with palette brand hex
secondary: '#5E92CE', // second series (prediction / forecast)
muted: '#99A9BD', // confidence band labels in tooltip
bandTop: '#B2D5F3', // confidence gradient top (opacity 0.4)
bandBot: '#CAE1F0', // confidence gradient bottom (opacity 0.15)
grid: '#E5E7EB', // CartesianGrid stroke (CSS overrides to #F3F4F6)
axisLine: '#E5E7EB', // X-axis line
text: '#111827', // heading text
textSec: '#6B7280', // subtitle, axis ticks, labels
today: '#9CA3AF', // ReferenceLine stroke
todayLabel:'#6B7280', // ReferenceLine label fill
};
const ALIAS = 'DATASET_ALIAS';
const DATE_COL = 'DATE_COL';
const VAL_COLS = ['VAL_COL_1'];
const TITLE = 'Chart Title';
const Y_LABEL = 'Units';
// Set true if the dataset has upper/lower confidence columns
const HAS_CONFIDENCE = false;
const CONF_UPPER = 'UPPER_COL';
const CONF_LOWER = 'LOWER_COL';
// ─────────────────────────────────────────────────────────────────────
// ── DATA PARSING ─────────────────────────────────────────────────────
const parseData = (raw) => {
if (!raw?.length) return [];
let headers, rows;
if (Array.isArray(raw[0])) {
headers = raw[0].map(h => String(h).toUpperCase().replace(/\./g, '_'));
rows = raw.slice(1);
} else {
headers = Object.keys(raw[0]).map(h => h.toUpperCase().replace(/\./g, '_'));
rows = raw.map(r => Object.keys(r).map(k => r[k]));
}
const find = (n) => headers.findIndex(h => h === n.toUpperCase() || h.includes(n.toUpperCase()));
const di = find(DATE_COL);
if (di === -1) { console.error('Date column not found. Headers:', headers); return []; }
const valIndices = VAL_COLS.map(c => ({ key: c, idx: find(c) })).filter(v => v.idx !== -1);
const upperIdx = HAS_CONFIDENCE ? find(CONF_UPPER) : -1;
const lowerIdx = HAS_CONFIDENCE ? find(CONF_LOWER) : -1;
return rows.map(r => {
const obj = { date: new Date(r[di]) };
valIndices.forEach(({ key, idx }) => { obj[key] = parseFloat(r[idx]) || null; });
if (upperIdx !== -1 && lowerIdx !== -1) {
const lo = parseFloat(r[lowerIdx]), hi = parseFloat(r[upperIdx]);
if (!isNaN(lo) && !isNaN(hi)) obj.confidenceRange = [lo, hi];
}
return obj;
}).filter(d => !isNaN(d.date.getTime()));
};
// ── AGGREGATION (Daily / Weekly / Monthly) ───────────────────────────
const aggregateData = (data, period) => {
const grouped = {};
data.forEach(item => {
let key;
const d = item.date;
switch (period) {
case 'week': {
const s = new Date(d); s.setDate(d.getDate() - d.getDay());
key = s.toISOString().split('T')[0]; break;
}
case 'month':
key = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-01`; break;
default:
key = d.toISOString().split('T')[0];
}
if (!grouped[key]) grouped[key] = { date: key, _counts: {} };
const g = grouped[key];
VAL_COLS.forEach(c => {
if (item[c] != null) {
g[c] = (g[c] || 0) + item[c];
g._counts[c] = (g._counts[c] || 0) + 1;
}
});
if (item.confidenceRange) g.confidenceRange = item.confidenceRange;
});
return Object.values(grouped)
.map(g => { const { _counts, ...rest } = g; return rest; })
.sort((a, b) => new Date(a.date) - new Date(b.date));
};
// ── FORMATTING ───────────────────────────────────────────────────────
const fmtNum = v => {
if (v >= 1e6) return `${(v/1e6).toFixed(1)}M`;
if (v >= 1e3) return `${(v/1e3).toFixed(0)}K`;
return `${Math.round(v)}`;
};
const fmtDate = (s, period) => {
const d = new Date(s);
if (period === 'month') return d.toLocaleDateString('en-US',{ month:'short', year:'numeric' });
return d.toLocaleDateString('en-US',{ month:'short', day:'numeric' });
};
const calcTickInterval = (len) => {
if (len <= 30) return 0;
if (len <= 90) return 6;
if (len <= 180) return 13;
return Math.ceil(len / 18) - 1;
};
// ── CUSTOM TOOLTIP (uses .custom-tooltip CSS class) ──────────────────
const ChartTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const d = new Date(label);
const fmt = d.toLocaleDateString('en-US',{ weekday:'short', month:'short', day:'numeric', year:'numeric' });
const pt = payload[0]?.payload;
return React.createElement('div', { className: 'custom-tooltip' },
React.createElement('p', { className: 'tooltip-date' }, fmt),
VAL_COLS.map((c, i) =>
pt?.[c] != null && React.createElement('p', { key: c,
style: { color: i === 0 ? COLORS.primary : COLORS.secondary, margin: 0 } },
`${c}: ${fmtNum(pt[c])}`)
),
HAS_CONFIDENCE && pt?.confidenceRange && [
React.createElement('p', { key:'lo', style:{ color: COLORS.muted, margin: 0 } },
`Lower: ${fmtNum(pt.confidenceRange[0])}`),
React.createElement('p', { key:'hi', style:{ color: COLORS.muted, margin: 0 } },
`Upper: ${fmtNum(pt.confidenceRange[1])}`)
]
);
};
// ── LOADING CELL DELAYS (row-major 3×3 grid) ────────────────────────
const LOAD_DELAYS = ['0s','0.1s','0.2s','0.1s','0.2s','0.3s','0.2s','0.3s','0.4s'];
// ── MAIN CHART COMPONENT ─────────────────────────────────────────────
const Chart = () => {
const [rawData, setRawData] = useState([]);
const [aggregation, setAggregation] = useState('day');
const [showBand, setShowBand] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const fetchAndParse = async () => {
try {
setLoading(true);
const data = await domo.get('/data/v1/' + ALIAS);
setRawData(parseData(data));
setError(null);
} catch (e) { setError(e.message || String(e)); }
finally { setLoading(false); }
};
useEffect(() => { fetchAndParse(); }, []);
useEffect(() => {
try {
if (typeof domo !== 'undefined' && domo.onFiltersUpdate) {
domo.onFiltersUpdate(() => fetchAndParse());
}
} catch (_) {}
}, []);
const chartData = useMemo(() => aggregateData(rawData, aggregation), [rawData, aggregation]);
const todayInRange = chartData.length > 0 &&
new Date(chartData[0].date) <= new Date(today) &&
new Date(chartData[chartData.length - 1].date) >= new Date(today);
if (loading) return React.createElement('div', { className: 'loading-container' },
React.createElement('div', { className: 'loading-grid' },
...[0,1,2,3,4,5,6,7,8].map(i =>
React.createElement('div', { key: i, className: 'loading-cell',
style: { animationDelay: LOAD_DELAYS[i] } })
)
),
React.createElement('p', null, 'Loading…')
);
if (error) return React.createElement('div', { className: 'error-container' },
React.createElement('h2', null, 'Error'),
React.createElement('p', null, error)
);
const dateRange = chartData.length > 0
? `${new Date(chartData[0].date).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})} – ${new Date(chartData[chartData.length-1].date).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})}`
: '';
const colorKeys = [COLORS.primary, COLORS.secondary];
return React.createElement('div', { className: 'chart-container' },
React.createElement('div', { className: 'chart-header' },
React.createElement('div', { className: 'chart-title-section' },
React.createElement('h1', null, TITLE),
React.createElement('p', { className: 'chart-subtitle' },
`${dateRange}, by ${aggregation === 'day' ? 'Day' : aggregation === 'week' ? 'Week' : 'Month'}`)
),
React.createElement('div', { style: { display:'flex', alignItems:'center', gap: 6 } },
HAS_CONFIDENCE && React.createElement('button', {
className: `confidence-toggle${showBand ? ' active' : ''}`,
onClick: () => setShowBand(b => !b)
},
React.createElement('span', { className: 'toggle-icon' }, '◉'),
'Band'
),
React.createElement('select', {
className: 'aggregation-select',
value: aggregation, onChange: e => setAggregation(e.target.value)
},
React.createElement('option', { value:'day' }, 'Daily'),
React.createElement('option', { value:'week' }, 'Weekly'),
React.createElement('option', { value:'month' }, 'Monthly')
)
)
),
React.createElement('div', { className: 'chart-wrapper', key: `w-${aggregation}` },
React.createElement(ResponsiveContainer, { width: '100%', height: '100%' },
React.createElement(ComposedChart, { data: chartData, margin: { top:10, right:20, left:10, bottom:20 } },
React.createElement('defs', null,
React.createElement('linearGradient', { id: 'confidenceGradient', x1:'0', y1:'0', x2:'0', y2:'1' },
React.createElement('stop', { offset:'0%', stopColor: COLORS.bandTop, stopOpacity: 0.4 }),
React.createElement('stop', { offset:'100%', stopColor: COLORS.bandBot, stopOpacity: 0.15 })
)
),
React.createElement(CartesianGrid, { strokeDasharray:'3 3', stroke: COLORS.grid, vertical: false }),
React.createElement(XAxis, {
dataKey: 'date', tickFormatter: d => fmtDate(d, aggregation),
stroke: COLORS.textSec, fontSize: 10, tickLine: false,
axisLine: { stroke: COLORS.axisLine },
height: aggregation === 'day' ? 40 : 30,
interval: aggregation === 'day' ? calcTickInterval(chartData.length) : 'preserveStartEnd'
}),
React.createElement(YAxis, {
tickFormatter: fmtNum, stroke: COLORS.textSec, fontSize: 12,
tickLine: false, axisLine: false,
label: { value: Y_LABEL, angle: -90, position: 'insideLeft',
style: { textAnchor: 'middle', fill: COLORS.textSec, fontSize: 12 } }
}),
React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
todayInRange && React.createElement(ReferenceLine, {
x: today, stroke: COLORS.today, strokeDasharray: '4 4',
label: { value: 'Today', position: 'top', fill: COLORS.todayLabel, fontSize: 11 }
}),
HAS_CONFIDENCE && showBand && React.createElement(Area, {
type: 'monotone', dataKey: 'confidenceRange',
stroke: 'none', fill: 'url(#confidenceGradient)', fillOpacity: 1,
name: 'Confidence Band', legendType: 'none'
}),
...VAL_COLS.map((c, i) => React.createElement(Line, {
key: c, type: 'monotone', dataKey: c,
stroke: colorKeys[i % colorKeys.length], strokeWidth: 2,
dot: false, activeDot: { r: 4, fill: colorKeys[i % colorKeys.length] },
name: c, connectNulls: false,
...(i > 0 ? { strokeDasharray: '4 3' } : {})
}))
)
)
),
React.createElement('div', { className: 'chart-footer' },
...VAL_COLS.map((c, i) =>
React.createElement('div', { key: c, className: 'legend-item' },
React.createElement('span', { className: `legend-line${i > 0 ? ' dashed' : ''}`,
style: { background: i > 0 ? undefined : colorKeys[i], color: colorKeys[i] } }),
React.createElement('span', null, c)
)
),
HAS_CONFIDENCE && React.createElement('div', { className: 'legend-item' },
React.createElement('span', { style: {
display: 'inline-block', width: 16, height: 12, borderRadius: 2,
background: `linear-gradient(180deg, rgba(178,213,243,0.4) 0%, rgba(202,225,240,0.15) 100%)`,
border: '1px solid rgba(94,146,206,0.4)'
} }),
React.createElement('span', null, 'Confidence Band')
)
)
);
};
createRoot(document.getElementById('app')).render(React.createElement(Chart));
放置位置:宽度 60,高度 30,hideTitle: true,fitToFrame: true,样式 ca1。放置在 HEADER 内容项下方。
逐页自定义:每个页面创建单独的应用目录。在各自的 app.js 中更改 ALIAS、DATE_COL、VAL_COL、TITLE、SUBTITLE 和 COLORS。
清单:使用空的 fields: [] 以避免列名不匹配。请包含 thumbnail.png(至少 300×300),否则 Domo 会返回 DA0087。
替代图表类型模板(子页面)
上面的折线图/面积图模板适用于概览页面。子页面必须使用不同的图表类型。以下是其他类型的 Recharts createElement 代码片段。它们都使用与折线图模板相同的 index.html、app.css、数据解析、加载/错误状态和提示框模式——只有图表渲染部分不同。
垂直柱状图(类别对比——例如按工厂划分的产量、按地区划分的收入):
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
// In render — replace ComposedChart+Line with:
React.createElement(ResponsiveContainer, { width: '100%', height: '100%' },
React.createElement(BarChart, { data: chartData, margin: { top: 10, right: 20, left: 10, bottom: 20 } },
React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: COLORS.grid, vertical: false }),
React.createElement(XAxis, {
dataKey: 'category', stroke: COLORS.textSec, fontSize: 10,
tickLine: false, axisLine: { stroke: COLORS.axisLine }
}),
React.createElement(YAxis, {
tickFormatter: fmtNum, stroke: COLORS.textSec, fontSize: 12,
tickLine: false, axisLine: false,
label: { value: Y_LABEL, angle: -90, position: 'insideLeft',
style: { textAnchor: 'middle', fill: COLORS.textSec, fontSize: 12 } }
}),
React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
React.createElement(Bar, { dataKey: 'value', fill: COLORS.primary, radius: [2, 2, 0, 0], maxBarSize: 40 })
)
)
堆叠柱状图(构成——例如按类型划分的缺陷、成本明细):
// Same imports as vertical bar. Data must have multiple value keys.
React.createElement(BarChart, { data: chartData, margin: { top: 10, right: 20, left: 10, bottom: 20 } },
React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: COLORS.grid, vertical: false }),
React.createElement(XAxis, { dataKey: 'category', stroke: COLORS.textSec, fontSize: 10, tickLine: false }),
React.createElement(YAxis, { tickFormatter: fmtNum, stroke: COLORS.textSec, fontSize: 12, tickLine: false, axisLine: false }),
React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
// One Bar per series, all stackId="stack"
React.createElement(Bar, { dataKey: 'series1', stackId: 'stack', fill: COLORS.primary, maxBarSize: 40 }),
React.createElement(Bar, { dataKey: 'series2', stackId: 'stack', fill: COLORS.secondary, maxBarSize: 40 }),
React.createElement(Bar, { dataKey: 'series3', stackId: 'stack', fill: COLORS.muted, radius: [2, 2, 0, 0], maxBarSize: 40 })
)
水平柱状图(排名——例如顶级供应商、缺陷原因、工厂绩效):
// BarChart with layout="vertical" flips axes
React.createElement(BarChart, {
data: chartData, layout: 'vertical',
margin: { top: 10, right: 20, left: 80, bottom: 10 } // left margin for category labels
},
React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: COLORS.grid, horizontal: false }),
React.createElement(XAxis, {
type: 'number', tickFormatter: fmtNum, stroke: COLORS.textSec, fontSize: 10
}),
React.createElement(YAxis, {
type: 'category', dataKey: 'category', stroke: COLORS.textSec, fontSize: 11,
width: 70, tickLine: false
}),
React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
React.createElement(Bar, { dataKey: 'value', fill: COLORS.primary, radius: [0, 2, 2, 0], maxBarSize: 24 })
)
散点图(相关性——例如周期时间与缺陷率、成本与交货周期):
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis } from 'recharts';
React.createElement(ResponsiveContainer, { width: '100%', height: '100%' },
React.createElement(ScatterChart, { margin: { top: 10, right: 20, left: 10, bottom: 20 } },
React.createElement(CartesianGrid, { strokeDasharray: '3 3', stroke: COLORS.grid }),
React.createElement(XAxis, {
dataKey: 'x', name: 'X Metric', stroke: COLORS.textSec, fontSize: 10,
label: { value: 'X Label', position: 'bottom', fill: COLORS.textSec, fontSize: 12 }
}),
React.createElement(YAxis, {
dataKey: 'y', name: 'Y Metric', stroke: COLORS.textSec, fontSize: 12,
label: { value: 'Y Label', angle: -90, position: 'insideLeft',
style: { textAnchor: 'middle', fill: COLORS.textSec, fontSize: 12 } }
}),
React.createElement(ZAxis, { dataKey: 'z', range: [40, 400], name: 'Size' }),
React.createElement(Tooltip, { content: React.createElement(ChartTooltip) }),
React.createElement(Scatter, { data: chartData, fill: COLORS.primary, fillOpacity: 0.7 })
)
)
各图表类型的数据结构:数据解析函数必须生成正确的数据结构:
- 垂直/堆叠柱状图:
[{ category: 'Plant A', value: 1234 }, ...]——按维度分组,对指标进行汇总 - 水平柱状图:与垂直柱状图相同,按值降序排序
- 散点图:
[{ x: 45.2, y: 3.1, z: 100, label: 'Plant A' }, ...]——两个数值轴 + 可选大小
已知问题和注意事项
设计缓存损坏
当 domo publish 更新现有设计(manifest.json 中的 id 相同)时,CDN 可能会缓存过期文件。症状:HTML 正常加载(存在 <div id="app">),ryuu.js 已加载,但来自 esm.sh 的 ESM 模块导入始终未触发——#app 的 innerHTML 为空。
诊断:对比正常图表的 iframe 网络请求(数十个 esm.sh 200 响应)与故障图表(没有 esm.sh 请求)。两者的 index.html 都包含相同的 importmap,但故障设计的 ESM 解析会静默失败。
修复:从 manifest.json 中移除 id 字段,然后再次运行 domo publish。这会创建一个使用全新 CDN 缓存的新设计。然后从新的设计 ID 创建新上下文和卡片实例。可以使用 DELETE /content/v1/cards/{cardId} 删除旧卡片。
上下文 API 路径
/domoapps/apps/v2/contexts 和 /domoapps/apps/v2 端点不使用 /api/ 前缀。它们位于根域名下:
POST https://{instance}.domo.com/domoapps/apps/v2/contextsPOST https://{instance}.domo.com/domoapps/apps/v2?fullpage=false&pageId=...
使用 https://{instance}.domo.com/api/domoapps/... 会返回 404 "No static resource"。
页面 ID 可能会变化
重建或修改 App Studio 应用时,页面 ID(viewId)可能会变化。务必通过 GET /content/v1/dataapps/{appId}?includeHiddenViews=true 读取当前应用结构,并使用 views[].viewId 获取当前页面 ID。切勿跨会话缓存页面 ID。
待解答的问题
- 紧凑模板的交互:紧凑模板的尺寸如何与具有自身响应式断点的自定义应用交互?
- 自定义应用卡片上的样式 ID:
sourceId样式(ca1–ca8)会影响自定义应用卡片容器的视觉效果,还是只影响原生卡片? acceptDateFilter与acceptFilters:日期筛选器是通过同一个onFiltersUpdated回调传递,还是通过单独的机制传递?