mcpField
A
app-studio-pro-code

app-studio-pro-code

github.com/stahura开发2438 次安装26 周前更新
代码仓库

关于 app-studio-pro-code

app-studio-pro-code 帮助在 Domo App Studio 页面中规划并嵌入 JavaScript 自定义应用卡片,适用于原生卡片无法满足需求的组件。它说明了页面布局、卡片实例创建,以及筛选器和变量与自定义应用的交互方式。

主要功能

  1. 01判断原生卡片或 Pro Code 的适用性
  2. 02规划页面组件与卡片布局
  3. 03构建并放置自定义应用卡片
  4. 04接收页面筛选器和变量变更
  5. 05读取及回写 App Studio 变量
  6. 06为可视化应用选择自定义调色板

适用场景

  1. 01制作甘特图、热力图等自定义可视化
  2. 02构建多步骤表单或复杂交互
  3. 03让页面筛选器驱动自定义应用数据
  4. 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 文档 CRUDX
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 步 — 配置专业代码卡片的布局

专业代码卡片通常需要比原生卡片更大的画布空间。建议尺寸:

专业代码应用类型宽度高度备注
全宽交互式(甘特图、时间线、地图)6030–40占满整行
半宽可视化(自定义图表、记分卡)3022–30可与另一张卡片并排
紧凑型小组件(状态指示器、迷你表单)2015–20一行可放三个
高型交互式(聊天、多步表单)20–3035–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" } }
}

暂存/提交模式:变量会在每次按键或选择时触发。请采用两阶段处理方式:

  1. onVariablesUpdated 将值存入 pendingValues(暂存区)
  2. 用户点击 Apply 按钮,将暂存值提交到活动状态
  3. 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 应用的主题。为了保持视觉一致性:

  1. 绝不使用 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
  2. 匹配页面背景:将自定义应用的 body 背景设为 transparent,使其与周围画布融为一体。
  3. 匹配卡片样式:Pro-code 容器应设置 background: transparent、不使用 box-shadow,且不设置 border-radius。App Studio 卡片框架会提供所有外观元素。
  4. 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 中运行的生产级专业代码应用:

  1. 筛选器传播正常:当同一页面上的原生筛选器卡片发生变化时,专业代码卡片中的 domo.onFiltersUpdated 会触发。内容条目需要设置 acceptFilters: true。筛选器对象使用 operand(而非 operator)。
  2. 变量传播正常:当 App Studio 变量控件发生变化时,domo.onVariablesUpdated 会触发。变量通过数字函数 ID 识别。domo.requestVariablesUpdate 支持将变量写回。
  3. 主题隔离已确认:专业代码卡片在隔离的 iframe 中渲染。App Studio 中没有 CSS 会被继承。自定义应用必须自行提供所有样式。
  4. 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/contexts
  • POST 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。

待解答的问题

  1. 紧凑模板的交互:紧凑模板的尺寸如何与具有自身响应式断点的自定义应用交互?
  2. 自定义应用卡片上的样式 ID:sourceId 样式(ca1–ca8)会影响自定义应用卡片容器的视觉效果,还是只影响原生卡片?
  3. acceptDateFilter 与 acceptFilters:日期筛选器是通过同一个 onFiltersUpdated 回调传递,还是通过单独的机制传递?

评论

支持 Markdown