Files
quantux_mcp/quantux_export.py

957 lines
36 KiB
Python

"""
Quant-UX prototype -> single self-contained interactive HTML exporter (Plan A).
Fetches the app model + its image assets through the REST API, then emits one
standalone .html file containing an embedded lightweight renderer. The file
works fully offline: double-click it in any browser and click through the
prototype (button -> screen navigation via the model's flow lines).
Supported widget types: Box, Label, Button, TextBox, Password, TextArea,
Image, Icon (basic), HotSpot, CheckBox, RadioBox, Switch, ToggleButton,
SegmentButton, SegmentPicker, DropDown. Interactions: click/dblclick/mouseover
flow lines (widget or group -> screen/widget), input typing, toggle controls.
Advanced widgets (charts, data grids, logic/data blocks) render as their
background box and are listed as unsupported.
Usage as CLI:
QUX_ADMIN_EMAIL=... QUX_ADMIN_PASSWORD=... \
python quantux_export.py --app <app_id> [--out out.html]
Usage as library:
from quantux_export import QuantUXExporter
path = QuantUXExporter(client).export(app_id)
"""
import argparse
import base64
import json
import os
import re
from quantux_client import QuantUXClient, QuantUXError
MIME = {
"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"gif": "image/gif", "svg": "image/svg+xml", "webp": "image/webp",
"bmp": "image/bmp", "ico": "image/x-icon",
}
# ---------------------------------------------------------------------------
# HTML template. {{MODEL}} / {{IMAGES}} / {{TITLE}} are replaced by the
# exporter (never use .format() on this string).
# ---------------------------------------------------------------------------
HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden;
background: #eceff3; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; }
#stage { position: fixed; inset: 0; display: flex; align-items: center;
justify-content: center; }
.qux-screen { position: relative; overflow: hidden; background-size: cover;
background-position: center; box-shadow: 0 8px 40px rgba(0,0,0,.25);
transform-origin: top left; transition: opacity .18s ease; }
.qux-widget { position: absolute; overflow: hidden; }
.qux-widget input, .qux-widget textarea {
width: 100%; height: 100%; border: none; outline: none; background: transparent;
font: inherit; color: inherit; padding: 0; }
.qux-hotspot { background: transparent !important; }
.qux-check, .qux-radio { display: inline-flex; align-items: center; gap: 6px;
width: 100%; height: 100%; font: inherit; color: inherit; }
.qux-check .qbox, .qux-radio .qbox {
width: 16px; height: 16px; min-width: 16px; border: 1.5px solid #9ca3af;
background: #fff; display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; line-height: 1; color: #fff; }
.qux-check .qbox { border-radius: 3px; }
.qux-radio .qbox { border-radius: 50%; }
.qux-check.qux-on .qbox, .qux-radio.qux-on .qbox { background: #4F46E5; border-color: #4F46E5; }
.qux-check.qux-on .qbox::after, .qux-radio.qux-on .qbox::after { content: "\2713"; }
.qux-switch { display: inline-flex; align-items: center; gap: 6px;
width: 100%; height: 100%; font: inherit; color: inherit; }
.qux-switch .qtrk { width: 34px; height: 18px; min-width: 34px; border-radius: 9px;
background: #d1d5db; position: relative; transition: background .15s; }
.qux-switch .qtrk::after { content: ""; position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%; background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: left .15s; }
.qux-switch.qux-on .qtrk { background: #4F46E5; }
.qux-switch.qux-on .qtrk::after { left: 18px; }
.qux-toggle { transition: filter .1s, box-shadow .1s; }
.qux-toggle.qux-on { filter: brightness(.82); box-shadow: inset 0 2px 4px rgba(0,0,0,.25); }
.qux-select { width: 100%; height: 100%; border: none; outline: none;
background: transparent; font: inherit; color: inherit; }
.qchart { width: 100%; height: 100%; display: block; }
.qtable { width: 100%; height: 100%; border-collapse: collapse;
font: inherit; color: inherit; }
.qtable th, .qtable td { padding: 4px 8px; text-align: left; font-weight: inherit; }
.qprogress { width: 100%; height: 100%; display: flex; align-items: center; }
.qprogress .qtrk { width: 100%; height: 60%; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
.qprogress .qfill { height: 100%; background: #4F46E5; border-radius: 4px; }
.qstars { width: 100%; height: 100%; display: flex; align-items: center; gap: 3px;
font-size: 18px; color: #d1d5db; }
.qstars .qstar { cursor: default; }
.qstars.qux-on .qstar { color: #f59e0b; }
.qstars .qstar.qux-on { color: #f59e0b; }
.qstepper { width: 100%; height: 100%; display: flex; align-items: center; gap: 6px;
font-size: 13px; color: #6b7280; }
.qstepper .qstep { padding: 2px 10px; border-radius: 12px; background: #e5e7eb; }
.qstepper .qstep.qux-on { background: #4F46E5; color: #fff; }
.qslider { width: 100%; height: 100%; display: flex; align-items: center; }
.qslider input { width: 100%; }
.qux-clickable { cursor: pointer; }
.qux-clickable:hover {
outline: 2px solid rgba(79,70,229,.6);
outline-offset: -2px;
}
.qux-clickable:hover::after {
content: "\25B6";
position: absolute; top: 2px; right: 4px;
font-size: 10px; color: rgba(79,70,229,.9);
background: rgba(255,255,255,.85); border-radius: 4px;
padding: 0 3px; line-height: 14px;
}
#screenName { position: fixed; left: 12px; bottom: 10px; z-index: 999;
background: rgba(15,23,42,.72); color: #fff; font-size: 12px;
padding: 4px 10px; border-radius: 12px; max-width: 60vw;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#hint { position: fixed; right: 12px; bottom: 10px; z-index: 999;
color: rgba(15,23,42,.5); font-size: 11px; }
</style>
</head>
<body>
<div id="stage"></div>
<div id="screenName"></div>
<div id="hint">Quant-UX 离线导出</div>
<script>
window.QUX_MODEL = {{MODEL}};
window.QUX_IMAGES = {{IMAGES}};
(function () {
"use strict";
var model = window.QUX_MODEL;
var images = window.QUX_IMAGES || {};
var stage = document.getElementById("stage");
var nameEl = document.getElementById("screenName");
var currentScreen = null;
function resolveImage(bg) {
if (!bg) return null;
var url = (typeof bg === "string") ? bg : (bg.url || bg.src || "");
return images[url] || null;
}
function num(v) { var n = parseFloat(v); return isNaN(n) ? 0 : n; }
function applyStyle(el, s) {
if (!s) return;
el.style.background = s.background || "transparent";
if (s.color) el.style.color = s.color;
if (s.fontSize && s.fontSize !== "Auto") el.style.fontSize = num(s.fontSize) + "px";
if (s.fontWeight) el.style.fontWeight = s.fontWeight;
if (s.textAlign) el.style.textAlign = s.textAlign;
if (s.letterSpacing) el.style.letterSpacing = num(s.letterSpacing) + "px";
if (s.lineHeight) el.style.lineHeight = String(s.lineHeight);
if (s.fontFamily) el.style.fontFamily = s.fontFamily;
if (s.textShadow) el.style.textShadow = s.textShadow;
el.style.borderTopWidth = num(s.borderTopWidth) + "px";
el.style.borderBottomWidth = num(s.borderBottomWidth) + "px";
el.style.borderRightWidth = num(s.borderRightWidth) + "px";
el.style.borderLeftWidth = num(s.borderLeftWidth) + "px";
if (s.borderTopColor) el.style.borderTopColor = s.borderTopColor;
if (s.borderBottomColor) el.style.borderBottomColor = s.borderBottomColor;
if (s.borderRightColor) el.style.borderRightColor = s.borderRightColor;
if (s.borderLeftColor) el.style.borderLeftColor = s.borderLeftColor;
el.style.borderTopLeftRadius = num(s.borderTopLeftRadius) + "px";
el.style.borderTopRightRadius = num(s.borderTopRightRadius) + "px";
el.style.borderBottomLeftRadius = num(s.borderBottomLeftRadius) + "px";
el.style.borderBottomRightRadius = num(s.borderBottomRightRadius) + "px";
el.style.paddingTop = num(s.paddingTop) + "px";
el.style.paddingBottom = num(s.paddingBottom) + "px";
el.style.paddingLeft = num(s.paddingLeft) + "px";
el.style.paddingRight = num(s.paddingRight) + "px";
if (s.boxShadow) el.style.boxShadow = s.boxShadow;
var bg = resolveImage(s.backgroundImage);
if (bg) {
el.style.backgroundImage = "url(" + bg + ")";
el.style.backgroundSize = "cover";
el.style.backgroundPosition = "center";
}
}
function baseEl(w) {
var el = document.createElement("div");
el.className = "qux-widget";
el.dataset.id = w.id;
el.style.left = num(w.x) + "px";
el.style.top = num(w.y) + "px";
el.style.width = num(w.w) + "px";
el.style.height = num(w.h) + "px";
el.style.zIndex = (num(w.z) + 1) || 1;
applyStyle(el, w.style);
return el;
}
function isGroupMember(groupId, widgetId) {
var g = (model.groups || {})[groupId];
if (!g) return false;
if ((g.children || []).indexOf(widgetId) >= 0) return true;
for (var i = 0; i < (g.groups || []).length; i++) {
if (isGroupMember(g.groups[i], widgetId)) return true;
}
return false;
}
function clickTarget(fromId, toId, depth) {
depth = depth || 0;
if (depth > 12) return;
if (model.screens[toId]) { renderScreen(toId); return; }
if (model.widgets[toId]) {
var lines = model.lines || {};
for (var key in lines) {
let l = lines[key];
if (l.from === toId) {
clickTarget(toId, l.to, depth + 1);
return;
}
}
}
}
function wire(el, widgetId) {
var lines = model.lines || {};
for (var key in lines) {
let l = lines[key];
var isFrom = (l.from === widgetId) || isGroupMember(l.from, widgetId);
if (!isFrom) continue;
var ev = l.event || "click";
if (ev === "click" || ev === "dblclick" || ev === "mouseover" || ev === "mouseenter") {
el.classList.add("qux-clickable");
el.addEventListener(ev, function (e) {
e.stopPropagation();
clickTarget(l.from, l.to);
});
}
}
}
function makeInput(w, type) {
var el = baseEl(w);
var inp = document.createElement(type === "area" ? "textarea" : "input");
if (type === "password") inp.type = "password";
else if (type === "text") inp.type = "text";
else if (type === "date") inp.type = "date";
var p = w.props || {};
if (p.placeholder) inp.placeholder = p.label || "";
else if (p.label && type !== "date") inp.value = p.label;
else if (p.value) inp.value = p.value;
el.appendChild(inp);
return el;
}
function makeToggle(w, kind) {
var el = baseEl(w);
el.classList.add("qux-" + kind);
var box = document.createElement("span");
box.className = "qbox";
var label = document.createElement("span");
label.textContent = (w.props && w.props.label) || "";
el.appendChild(box);
el.appendChild(label);
el.addEventListener("click", function () {
el.classList.toggle("qux-on");
});
return el;
}
function makeSwitch(w) {
var el = baseEl(w);
el.classList.add("qux-switch");
var track = document.createElement("span");
track.className = "qtrk";
var label = document.createElement("span");
label.textContent = (w.props && w.props.label) || "";
el.appendChild(track);
el.appendChild(label);
el.addEventListener("click", function () {
el.classList.toggle("qux-on");
});
return el;
}
function makeDropDown(w) {
var el = baseEl(w);
var sel = document.createElement("select");
sel.className = "qux-select";
var p = w.props || {};
var options = Array.isArray(p.options) ? p.options : [];
if (p.label && options.length === 0) {
var ph = document.createElement("option");
ph.textContent = p.label;
ph.value = "";
sel.appendChild(ph);
}
options.forEach(function (o) {
var opt = document.createElement("option");
if (typeof o === "object" && o !== null) {
opt.textContent = o.label != null ? o.label : o.value;
opt.value = o.value != null ? o.value : o.label;
} else {
opt.textContent = o;
opt.value = o;
}
sel.appendChild(opt);
});
el.appendChild(sel);
return el;
}
function iconGlyph(w) {
var p = w.props || {};
var name = String(p.icon || p.name || "").toLowerCase();
var map = {
"arrow-right": "\u2192", "arrow-left": "\u2190", "arrow-up": "\u2191",
"arrow-down": "\u2193", "check": "\u2713", "close": "\u2715",
"search": "\u2315", "home": "\u2302", "user": "\uD83D\uDC64",
"mail": "\u2709", "phone": "\u260E", "lock": "\uD83D\uDD12",
"settings": "\u2699", "star": "\u2605", "heart": "\u2665",
"cart": "\uD83D\uDED2", "plus": "+", "minus": "\u2212", "play": "\u25B6",
"pause": "\u23F8", "trash": "\uD83D\uDDD1", "edit": "\u270E",
"download": "\u2193", "upload": "\u2191", "share": "\u21A9",
"info": "\u24D8", "warning": "\u26A0", "menu": "\u2630",
};
return map[name] || "\u25CF";
}
// ---------------------------------------------------------- data widgets
var PALETTE = ["#4F46E5", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6",
"#06B6D4", "#EC4899", "#84CC16", "#F97316", "#14B8A6"];
function chartData(props) {
var raw = props.data;
var labels = [], series = [];
if (!Array.isArray(raw)) return { labels: labels, series: series };
raw.forEach(function (row, ri) {
if (!Array.isArray(row)) return;
var label = null, nums = [];
row.forEach(function (c) {
var n = Number(c);
if (isNaN(n)) { if (label === null) label = String(c); }
else nums.push(n);
});
if (label === null) label = "#" + (ri + 1);
labels.push(label);
nums.forEach(function (v, si) {
if (!series[si]) series[si] = { name: "系列" + (si + 1), values: [] };
series[si].values.push(v);
});
});
return { labels: labels, series: series };
}
function chartMax(series) {
var m = 0;
series.forEach(function (s) { s.values.forEach(function (v) { m = Math.max(m, Math.abs(v)); }); });
return m || 1;
}
function renderBarChart(el, w) {
var p = w.props || {};
var d = chartData(p);
var svgNS = "http://www.w3.org/2000/svg";
var svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("class", "qchart");
svg.setAttribute("viewBox", "0 0 100 100");
svg.setAttribute("preserveAspectRatio", "none");
var pad = 8, max = chartMax(d.series);
var n = d.labels.length || 1;
var horizontal = !!p.isHorizontal;
if (p.isLine || d.series.length > 0 && p.isLine) { renderLineSVG(svg, d, max, pad, n); el.appendChild(svg); return; }
var seriesCount = Math.max(1, d.series.length);
var groupW = horizontal ? 90 / n : 80 / n;
var barW = groupW / (seriesCount + 0.4);
d.labels.forEach(function (label, i) {
var g = document.createElementNS(svgNS, "g");
d.series.forEach(function (s, si) {
var v = s.values[i] || 0;
var h = 90 * Math.abs(v) / max;
var x, y, bw, bh;
if (horizontal) {
bw = h; bh = barW * 0.8;
x = pad; y = 6 + i * groupW + si * (bh + 1);
} else {
bw = barW * 0.8; bh = h;
x = 10 + i * groupW + si * (bw + 1); y = 96 - h;
}
var rect = document.createElementNS(svgNS, "rect");
rect.setAttribute("x", x); rect.setAttribute("y", y);
rect.setAttribute("width", bw); rect.setAttribute("height", bh);
rect.setAttribute("fill", PALETTE[si % PALETTE.length]);
rect.setAttribute("rx", "1");
if (horizontal) rect.setAttribute("y", y + (bh - Math.max(2, bh)) * 0.5);
g.appendChild(rect);
});
var tx = horizontal ? 2 : 10 + i * groupW + groupW / 2;
var ty = horizontal ? 8 + i * groupW + 3 : 98.5;
var txt = document.createElementNS(svgNS, "text");
txt.setAttribute("x", tx); txt.setAttribute("y", ty);
txt.setAttribute("font-size", "4"); txt.setAttribute("fill", "#6b7280");
txt.setAttribute("text-anchor", horizontal ? "start" : "middle");
txt.textContent = String(label).slice(0, 12);
g.appendChild(txt);
svg.appendChild(g);
});
el.appendChild(svg);
}
function renderLineSVG(svg, d, max, pad, n) {
var svgNS = "http://www.w3.org/2000/svg";
d.series.forEach(function (s, si) {
var pts = [];
s.values.forEach(function (v, i) {
var x = pad + i * (100 - 2 * pad) / Math.max(1, s.values.length - 1);
var y = 94 - 88 * v / max;
pts.push(x + "," + y);
var c = document.createElementNS(svgNS, "circle");
c.setAttribute("cx", x); c.setAttribute("cy", y); c.setAttribute("r", "2");
c.setAttribute("fill", PALETTE[si % PALETTE.length]);
svg.appendChild(c);
});
if (pts.length > 1) {
var pl = document.createElementNS(svgNS, "polyline");
pl.setAttribute("points", pts.join(" "));
pl.setAttribute("fill", "none");
pl.setAttribute("stroke", PALETTE[si % PALETTE.length]);
pl.setAttribute("stroke-width", "2");
svg.appendChild(pl);
}
});
}
function renderPieChart(el, w, ring) {
var p = w.props || {};
var svgNS = "http://www.w3.org/2000/svg";
var svg = document.createElementNS(svgNS, "svg");
svg.setAttribute("class", "qchart");
svg.setAttribute("viewBox", "0 0 100 100");
var pairs = [];
var raw = p.data;
if (Array.isArray(raw)) {
raw.forEach(function (row) {
if (Array.isArray(row)) {
for (var i = 0; i + 1 < row.length; i += 2) {
var v = Number(row[i + 1]);
if (!isNaN(v)) pairs.push({ label: String(row[i]), value: v });
}
}
});
}
var total = 0;
pairs.forEach(function (x) { total += x.value; });
if (!total) { pairs = [{ label: "暂无数据", value: 1 }]; total = 1; }
var cx = 50, cy = 50, r = ring ? 34 : 44, r2 = ring ? 20 : 0;
var a0 = -Math.PI / 2;
pairs.forEach(function (x, i) {
var a1 = a0 + 2 * Math.PI * x.value / total;
var x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
var x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
var large = (a1 - a0) > Math.PI ? 1 : 0;
var path = document.createElementNS(svgNS, "path");
var dpath = "M" + cx + "," + cy + " L" + x0 + "," + y0 + " A" + r + "," + r +
" 0 " + large + " 1 " + x1 + "," + y1 + " Z";
if (ring) {
var xi0 = cx + r2 * Math.cos(a0), yi0 = cy + r2 * Math.sin(a0);
var xi1 = cx + r2 * Math.cos(a1), yi1 = cy + r2 * Math.sin(a1);
dpath = "M" + x0 + "," + y0 + " A" + r + "," + r + " 0 " + large + " 1 " + x1 + "," + y1 +
" L" + xi1 + "," + yi1 + " A" + r2 + "," + r2 + " 0 " + large + " 0 " + xi0 + "," + yi0 + " Z";
}
path.setAttribute("d", dpath);
path.setAttribute("fill", PALETTE[i % PALETTE.length]);
svg.appendChild(path);
a0 = a1;
});
if (ring && p.value) {
var t = document.createElementNS(svgNS, "text");
t.setAttribute("x", cx); t.setAttribute("y", cy + 2);
t.setAttribute("text-anchor", "middle"); t.setAttribute("font-size", "8");
t.setAttribute("fill", "#374151"); t.setAttribute("font-weight", "bold");
t.textContent = String(p.value);
svg.appendChild(t);
}
el.appendChild(svg);
}
function renderTable(el, w) {
var p = w.props || {};
var raw = Array.isArray(p.data) ? p.data : [];
var columns = Array.isArray(p.columns) && p.columns.length
? p.columns.map(function (c) { return typeof c === "object" ? (c.label != null ? c.label : c.value) : c; })
: (raw.length ? raw[0].map(function (c) { return String(c); }) : []);
var rows = (Array.isArray(p.columns) && p.columns.length && raw.length) ? raw : raw.slice(1);
var table = document.createElement("table");
table.className = "qtable";
var s = w.style || {};
if (s.background) table.style.background = s.background;
if (s.color) table.style.color = s.color;
if (s.fontSize) table.style.fontSize = num(s.fontSize) + "px";
var thead = document.createElement("thead");
var htr = document.createElement("tr");
columns.forEach(function (c) {
var th = document.createElement("th");
th.textContent = c;
th.style.border = "1px solid " + (s.borderTopColor || "#e5e7eb");
htr.appendChild(th);
});
thead.appendChild(htr);
table.appendChild(thead);
var tbody = document.createElement("tbody");
rows.forEach(function (row) {
var tr = document.createElement("tr");
row.forEach(function (cell) {
var td = document.createElement("td");
td.textContent = cell == null ? "" : String(cell);
td.style.border = "1px solid " + (s.borderTopColor || "#e5e7eb");
tr.appendChild(td);
});
tbody.appendChild(tr);
});
table.appendChild(tbody);
el.appendChild(table);
}
function renderProgress(el, w) {
var v = Math.max(0, Math.min(100, Number((w.props || {}).value) || 0));
el.classList.add("qprogress");
var trk = document.createElement("div"); trk.className = "qtrk";
var fill = document.createElement("div"); fill.className = "qfill";
fill.style.width = v + "%";
trk.appendChild(fill); el.appendChild(trk);
}
function renderRating(el, w) {
var v = Math.round(Number((w.props || {}).value) || 0);
var max = Math.round(Number((w.props || {}).max) || 5);
el.classList.add("qstars");
for (var i = 1; i <= max; i++) {
var star = document.createElement("span");
star.className = "qstar" + (i <= v ? " qux-on" : "");
star.textContent = "\u2605";
el.appendChild(star);
}
}
function renderStepper(el, w) {
var p = w.props || {};
var v = Number(p.value) || 0;
var steps = Array.isArray(p.steps) ? p.steps
: Array.isArray(p.data) && p.data.length ? p.data.map(function (x) { return Array.isArray(x) ? x[0] : x; }) : [];
if (!steps.length) steps = ["步骤" + (v + 1)];
el.classList.add("qstepper");
steps.forEach(function (s, i) {
var step = document.createElement("span");
step.className = "qstep" + (i === v ? " qux-on" : "");
step.textContent = String(s);
el.appendChild(step);
});
}
function renderSlider(el, w, kind) {
var p = w.props || {};
var inp = document.createElement("input");
inp.type = "range";
inp.min = 0; inp.max = 100; inp.value = Number(p.value) || 0;
el.classList.add("qslider");
el.appendChild(inp);
}
function renderIFrame(el, w) {
var p = w.props || {};
var iframe = document.createElement("iframe");
iframe.style.width = "100%"; iframe.style.height = "100%";
iframe.style.border = "none";
iframe.src = p.url || p.src || "";
el.appendChild(iframe);
}
function renderNavMenu(el, w) {
var p = w.props || {};
var items = Array.isArray(p.items) ? p.items
: Array.isArray(p.data) ? p.data.map(function (x) { return Array.isArray(x) ? x[0] : x; }) : [];
el.style.display = "flex";
el.style.alignItems = "center";
el.style.gap = "10px";
el.style.padding = "0 8px";
el.style.overflowX = "auto";
items.forEach(function (it) {
var span = document.createElement("span");
span.textContent = typeof it === "object" ? (it.label || it.value || "") : String(it);
span.style.color = (w.style || {}).color || "#fff";
span.style.fontSize = ((w.style || {}).fontSize || 14) + "px";
el.appendChild(span);
});
}
// ------------------------------------------- Repeater / DataList 模板重复
function childBoundingBox(children) {
var bx = null;
children.forEach(function (id) {
var cw = model.widgets[id];
if (!cw) return;
if (!bx) { bx = { x: cw.x, y: cw.y, w: cw.w, h: cw.h }; }
else {
bx.x = Math.min(bx.x, cw.x);
bx.y = Math.min(bx.y, cw.y);
bx.w = Math.max(bx.w, cw.x + cw.w);
bx.h = Math.max(bx.h, cw.y + cw.h);
}
});
if (bx) { bx.w -= bx.x; bx.h -= bx.y; }
return bx;
}
function renderRepeater(el, w) {
var p = w.props || {};
var children = Array.isArray(w.children) ? w.children : [];
if (!children.length) return;
var bx = childBoundingBox(children);
if (!bx) return;
var s = w.style || {};
var innerW = num(w.w) - num(s.borderLeftWidth) - num(s.borderRightWidth);
var innerH = num(w.h) - num(s.borderTopWidth) - num(s.borderBottomWidth);
var offsetX = bx.x - num(w.x);
var offsetY = bx.y - num(w.y);
var width = innerW - offsetX * 2;
var height = innerH - offsetY * 2;
var sx = Number(p.distanceX); if (isNaN(sx)) sx = -1;
var sy = Number(p.distanceY); if (isNaN(sy)) sy = -1;
var isGrid = p.layout !== "rows";
var childWidth = sx < 0 ? bx.w : bx.w + sx;
var childHeight = sy < 0 ? bx.h : bx.h + sy;
var columns = Math.max(isGrid ? Math.floor(width / Math.max(1, childWidth)) : 1, 1);
var rows = Math.max(Math.floor(height / Math.max(1, childHeight)), 1);
if (sx < 0) { var restW = width - columns * childWidth; sx = Math.max(0, Math.floor(restW / Math.max(1, columns - 1))); }
if (sy < 0) { var restH = height - rows * childHeight; sy = Math.max(0, Math.floor(restH / Math.max(1, rows - 1))); }
for (var r = 0; r < rows; r++) {
for (var c = 0; c < columns; c++) {
children.forEach(function (id) {
var cw = model.widgets[id];
if (!cw) return;
var copy = renderWidget(cw);
copy.style.left = (offsetX + c * childWidth + (cw.x - bx.x)) + "px";
copy.style.top = (offsetY + r * childHeight + (cw.y - bx.y)) + "px";
wire(copy, id);
el.appendChild(copy);
});
}
}
}
function renderWidget(w) {
var el;
switch (w.type) {
case "Label": {
el = baseEl(w);
el.textContent = (w.props && w.props.label) || "";
break;
}
case "Button":
case "ToggleButton":
case "SegmentButton":
case "SegmentPicker": {
el = baseEl(w);
el.textContent = (w.props && w.props.label) || "";
el.style.display = "flex";
el.style.alignItems = "center";
el.style.justifyContent = "center";
if (w.type !== "Button") {
el.classList.add("qux-toggle");
el.addEventListener("click", function () {
el.classList.toggle("qux-on");
});
}
break;
}
case "CheckBox": el = makeToggle(w, "check"); break;
case "RadioBox": el = makeToggle(w, "radio"); break;
case "Switch": el = makeSwitch(w); break;
case "DropDown": el = makeDropDown(w); break;
case "TextBox": el = makeInput(w, "text"); break;
case "Password": el = makeInput(w, "password"); break;
case "TextArea": el = makeInput(w, "area"); break;
case "Image": {
el = baseEl(w);
var p = w.props || {};
var url = resolveImage(w.style && w.style.backgroundImage) ||
images[p.url || p.src] || null;
if (url) {
el.style.backgroundImage = "url(" + url + ")";
el.style.backgroundSize = "cover";
el.style.backgroundPosition = "center";
}
break;
}
case "Icon": {
el = baseEl(w);
el.textContent = iconGlyph(w);
el.style.display = "flex";
el.style.alignItems = "center";
el.style.justifyContent = "center";
el.style.fontSize = (w.style && w.style.fontSize) ? num(w.style.fontSize) + "px" : "18px";
break;
}
case "HotSpot": el = baseEl(w); el.classList.add("qux-hotspot"); break;
case "BarChart": el = baseEl(w); renderBarChart(el, w); break;
case "LineChart": el = baseEl(w); renderBarChart(el, w); break; // isLine 模式
case "PieChart": el = baseEl(w); renderPieChart(el, w, false); break;
case "RingChart": el = baseEl(w); renderPieChart(el, w, true); break;
case "MultiRingChart":
case "StackedRingChart":
el = baseEl(w); renderPieChart(el, w, true); break;
case "Table":
case "DataTable":
case "RadioTable":
el = baseEl(w); renderTable(el, w); break;
case "ProgressBar": el = baseEl(w); renderProgress(el, w); break;
case "Rating": el = baseEl(w); renderRating(el, w); break;
case "Stepper": el = baseEl(w); renderStepper(el, w); break;
case "HSlider":
case "VolumeSlider":
case "LockSlider":
el = baseEl(w); renderSlider(el, w); break;
case "IFrameWidget": el = baseEl(w); renderIFrame(el, w); break;
case "QDate": case "QDateDropDown":
el = makeInput(w, "date"); break;
case "NavBar":
case "NavMenu":
case "VerticalNavigation":
el = baseEl(w); renderNavMenu(el, w); break;
case "SortableList": case "Tree":
el = baseEl(w);
el.textContent = (w.props && (w.props.label || (w.props.data && String(w.props.data)))) || w.name || w.type;
break;
case "Repeater":
case "DataList":
el = baseEl(w);
renderRepeater(el, w);
break;
default:
el = baseEl(w);
}
return el;
}
function startScreenId() {
var start = model.startScreen;
if (start && model.screens[start]) return start;
var first = null;
for (var key in model.screens) {
var s = model.screens[key];
if (!first) first = key;
if (s.props && s.props.start) return key;
}
return first;
}
function getScreenAnim(scr) {
var a = scr.animation;
if (!a) return null;
if (typeof a !== "object") return null;
var anim = a.enter || a.show || a["in"] || a.onEnter || null;
if (!anim) {
for (var k in a) {
if (a[k] && typeof a[k] === "object") { anim = a[k]; break; }
}
}
return anim && typeof anim === "object" ? anim : null;
}
function animTransform(anim, out) {
var t = (anim && anim.type) || "fade";
if (out) {
if (t.indexOf("slide") === 0) return "translateX(60px)";
if (t === "zoom" || t === "grow") return "scale(0.94)";
return "";
}
if (t.indexOf("slide") === 0) {
if (t.indexOf("up") >= 0) return "translateY(-60px)";
if (t.indexOf("down") >= 0) return "translateY(60px)";
if (t.indexOf("left") >= 0) return "translateX(-60px)";
return "translateX(60px)";
}
if (t === "zoom" || t === "grow") return "scale(0.94)";
return "";
}
function animDuration(anim) {
var d = Number(anim && anim.duration);
if (isNaN(d) || d <= 0) return 300;
return Math.max(100, Math.min(2000, d * 1000));
}
function animEasing(anim) {
var e = (anim && anim.easing) || "";
var map = { "linear": "linear", "ease": "ease", "ease-in": "ease-in",
"ease-out": "ease-out", "ease-in-out": "ease-in-out" };
return map[e] || "ease";
}
function renderScreen(id) {
var scr = model.screens[id];
if (!scr) return;
currentScreen = id;
stage.innerHTML = "";
var layer = document.createElement("div");
layer.style.width = num(scr.w) + "px";
layer.style.height = num(scr.h) + "px";
var holder = document.createElement("div");
holder.className = "qux-screen";
holder.style.width = num(scr.w) + "px";
holder.style.height = num(scr.h) + "px";
applyStyle(holder, scr.style);
(scr.children || []).forEach(function (wid) {
var w = model.widgets[wid];
if (!w) return;
var el = renderWidget(w);
wire(el, wid);
holder.appendChild(el);
});
layer.appendChild(holder);
stage.appendChild(layer);
nameEl.textContent = scr.name || "Screen";
var anim = getScreenAnim(scr);
if (anim) {
layer.style.transition = "transform " + animDuration(anim) + "ms " +
animEasing(anim) + ", opacity " + animDuration(anim) + "ms " +
animEasing(anim);
layer.style.opacity = "0";
layer.style.transform = animTransform(anim, true);
requestAnimationFrame(function () {
requestAnimationFrame(function () {
layer.style.opacity = "1";
layer.style.transform = animTransform(anim, false);
});
});
}
fit();
}
function fit() {
var scr = model.screens[currentScreen];
var layer = stage.firstChild;
var holder = layer ? layer.firstChild : null;
if (!scr || !holder) return;
var s = Math.min(window.innerWidth / num(scr.w), window.innerHeight / num(scr.h), 1);
holder.style.transform = "scale(" + s + ")";
}
window.addEventListener("resize", fit);
renderScreen(startScreenId());
})();
</script>
</body>
</html>
"""
def _sanitize(name):
return re.sub(r"[^\w\u4e00-\u9fff\-]+", "_", name).strip("_") or "prototype"
class QuantUXExporter:
"""Export a Quant-UX app to a self-contained interactive HTML file."""
def __init__(self, client):
self.client = client
# ------------------------------------------------------------- images
def collect_image_urls(self, model):
urls = {}
def add(bg):
if not bg:
return
u = bg.get("url") or bg.get("src") if isinstance(bg, dict) else bg
if isinstance(u, str) and u and u not in urls:
urls[u] = u
for scr in model.get("screens", {}).values():
add((scr.get("style") or {}).get("backgroundImage"))
for w in model.get("widgets", {}).values():
add((w.get("style") or {}).get("backgroundImage"))
p = w.get("props") or {}
for key in ("url", "src", "image"):
if isinstance(p.get(key), str):
add(p[key])
return list(urls.keys())
def fetch_image(self, url):
token = self.client.token or ""
sep = "&" if "?" in url else "?"
u = f"{self.client.base_url}/rest/images/{url}{sep}token={token}"
resp = self.client.session.get(u, timeout=self.client.timeout)
if resp.status_code != 200:
raise QuantUXError(f"image fetch HTTP {resp.status_code} for {url}")
return resp.content
def fetch_all_images(self, urls):
result = {}
for url in urls:
try:
data = self.fetch_image(url)
ext = url.rsplit(".", 1)[-1].lower() if "." in url else "png"
mime = MIME.get(ext, "image/png")
result[url] = "data:" + mime + ";base64," + base64.b64encode(data).decode()
except Exception as exc: # noqa: BLE001
print(f"WARN: could not fetch image {url}: {exc}")
return result
# --------------------------------------------------------------- html
def build_html(self, model, images):
title = model.get("name") or "Quant-UX Prototype"
html = HTML_TEMPLATE
html = html.replace("{{TITLE}}", title)
html = html.replace(
"{{MODEL}}", json.dumps(model, ensure_ascii=False)
)
html = html.replace(
"{{IMAGES}}", json.dumps(images, ensure_ascii=False)
)
return html
def export(self, app_id, out_path=None):
model = self.client.get_app(app_id)
urls = self.collect_image_urls(model)
images = self.fetch_all_images(urls)
html = self.build_html(model, images)
if not out_path:
out_path = _sanitize(model.get("name") or app_id) + ".html"
with open(out_path, "w", encoding="utf-8") as fh:
fh.write(html)
return out_path
# ---------------------------------------------------------------------- CLI
def main():
parser = argparse.ArgumentParser(description="Export a Quant-UX prototype to a local interactive HTML file")
parser.add_argument("--app", required=True, help="Quant-UX app id")
parser.add_argument("--out", default="", help="output html path")
parser.add_argument("--base", default=os.environ.get("QUX_BASE_URL", "http://127.0.0.1:8082"))
parser.add_argument("--email", default=os.environ.get("QUX_ADMIN_EMAIL", ""))
parser.add_argument("--password", default=os.environ.get("QUX_ADMIN_PASSWORD", ""))
args = parser.parse_args()
client = QuantUXClient(args.base)
if args.email and args.password:
client.login(args.email, args.password)
else:
raise SystemExit("login required: pass --email/--password or set QUX_ADMIN_EMAIL/QUX_ADMIN_PASSWORD")
path = QuantUXExporter(client).export(args.app, args.out)
print(f"exported: {path} ({os.path.getsize(path)} bytes)")
if __name__ == "__main__":
main()