957 lines
36 KiB
Python
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()
|