Publish dataease via gitea-publish skill
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const MAX_DEBUG_LOGS = 20;
|
||||
const RENDER_SETTLE_POLL_MS = 500;
|
||||
const RENDER_SETTLE_IDLE_MS = 2000;
|
||||
const MAX_CAPTURE_VIEWPORT_HEIGHT = 12000;
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/browser_capture.mjs --url <url> --token <x-de-token> --width 1920 --height 1080 --wait-seconds 0 --result-format 0 --output /abs/path/file.jpg
|
||||
|
||||
Options:
|
||||
--url DataEase preview URL
|
||||
--token X-DE-TOKEN to inject into localStorage as user.token
|
||||
--width Browser viewport width in pixels
|
||||
--height Browser viewport height in pixels
|
||||
--wait-seconds Extra wait time after canvas is visible
|
||||
--result-format 0=jpeg, 1=pdf
|
||||
--output Absolute or relative output path
|
||||
-h, --help Show this help message
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const current = argv[index];
|
||||
if (current === '-h' || current === '--help') {
|
||||
args.help = true;
|
||||
continue;
|
||||
}
|
||||
if (!current.startsWith('--')) {
|
||||
throw new Error(`Unexpected argument: ${current}`);
|
||||
}
|
||||
const key = current.slice(2);
|
||||
const value = argv[index + 1];
|
||||
if (value == null || value.startsWith('--')) {
|
||||
throw new Error(`Missing value for --${key}`);
|
||||
}
|
||||
args[key] = value;
|
||||
index += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, name) {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(number) || number <= 0) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function parseWaitSeconds(value) {
|
||||
const number = Number.parseInt(value ?? '0', 10);
|
||||
if (!Number.isFinite(number) || number < 0) {
|
||||
throw new Error('wait-seconds must be a non-negative integer');
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
async function waitForCanvasReady(page, selector, timeout) {
|
||||
const locator = page.locator(selector).first();
|
||||
await locator.waitFor({ state: 'visible', timeout });
|
||||
await page.waitForFunction(
|
||||
targetSelector => {
|
||||
const element = document.querySelector(targetSelector);
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
},
|
||||
selector,
|
||||
{ timeout }
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
return locator;
|
||||
}
|
||||
|
||||
function buildWsCacheItem(value, now = Date.now(), expiresAt = 253402300799000) {
|
||||
return JSON.stringify({
|
||||
c: now,
|
||||
e: expiresAt,
|
||||
v: JSON.stringify(value)
|
||||
});
|
||||
}
|
||||
|
||||
async function collectDiagnostics(page, debugLogs) {
|
||||
const state = await page.evaluate(() => {
|
||||
const app = document.querySelector('#app');
|
||||
const visible = element => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || '1') > 0 && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
return {
|
||||
href: location.href,
|
||||
title: document.title,
|
||||
hasCanvas: !!document.querySelector('.canvas-container'),
|
||||
hasContent: !!document.querySelector('.content'),
|
||||
hasEmpty: !!document.querySelector('.empty-background, .el-empty'),
|
||||
visibleLoadingMasks: Array.from(document.querySelectorAll('.el-loading-mask,.ed-loading-mask,.v-loading-mask')).filter(visible).length,
|
||||
unfinishedReportLoads: document.querySelectorAll('.report-load:not(.report-load-finish)').length,
|
||||
bodyText: (document.body?.innerText || '').slice(0, 400),
|
||||
appHtml: (app?.innerHTML || '').slice(0, 1200)
|
||||
};
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
logs: debugLogs.slice(-MAX_DEBUG_LOGS)
|
||||
};
|
||||
}
|
||||
|
||||
async function getRenderState(page, selector) {
|
||||
return page.evaluate(targetSelector => {
|
||||
const visible = element => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || '1') > 0 && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const canvas = document.querySelector(targetSelector);
|
||||
const loadingSelectors = '.el-loading-mask,.ed-loading-mask,.v-loading-mask,[class*="loading-mask"]';
|
||||
const visibleLoadingMasks = Array.from(document.querySelectorAll(loadingSelectors)).filter(visible).length;
|
||||
const unfinishedReportLoads = document.querySelectorAll('.report-load:not(.report-load-finish)').length;
|
||||
const box = canvas?.getBoundingClientRect();
|
||||
return {
|
||||
hasCanvas: !!canvas,
|
||||
visibleLoadingMasks,
|
||||
unfinishedReportLoads,
|
||||
width: Math.round(box?.width || 0),
|
||||
height: Math.round(box?.height || 0)
|
||||
};
|
||||
}, selector);
|
||||
}
|
||||
|
||||
async function waitForRenderSettled(page, selector, timeout, debugLogs, getPendingRequests) {
|
||||
const startedAt = Date.now();
|
||||
let stableSince = 0;
|
||||
let lastSnapshot = '';
|
||||
|
||||
while (Date.now() - startedAt < timeout) {
|
||||
const state = await getRenderState(page, selector);
|
||||
const pendingRequests = getPendingRequests();
|
||||
const snapshot = JSON.stringify({ ...state, pendingRequests });
|
||||
if (snapshot !== lastSnapshot) {
|
||||
debugLogs.push(`render-state ${snapshot}`);
|
||||
lastSnapshot = snapshot;
|
||||
}
|
||||
|
||||
const settled =
|
||||
state.hasCanvas &&
|
||||
state.width > 0 &&
|
||||
state.height > 0 &&
|
||||
state.visibleLoadingMasks === 0 &&
|
||||
state.unfinishedReportLoads === 0 &&
|
||||
pendingRequests === 0;
|
||||
|
||||
if (settled) {
|
||||
if (!stableSince) {
|
||||
stableSince = Date.now();
|
||||
}
|
||||
if (Date.now() - stableSince >= RENDER_SETTLE_IDLE_MS) {
|
||||
return state;
|
||||
}
|
||||
} else {
|
||||
stableSince = 0;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(RENDER_SETTLE_POLL_MS);
|
||||
}
|
||||
|
||||
const state = await getRenderState(page, selector);
|
||||
throw new Error(
|
||||
`render settle timeout: ${JSON.stringify({
|
||||
...state,
|
||||
pendingRequests: getPendingRequests()
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
async function expandScrollableCapture(page, selector, viewportWidth, viewportHeight) {
|
||||
return page.evaluate(
|
||||
({ targetSelector, maxViewportHeight, minViewportHeight, viewportWidth }) => {
|
||||
const canvas = document.querySelector(targetSelector);
|
||||
if (!canvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isScrollable = element => {
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
const overflowY = style.overflowY;
|
||||
return ['auto', 'scroll', 'overlay'].includes(overflowY) && element.scrollHeight - element.clientHeight > 2;
|
||||
};
|
||||
|
||||
const applyStyle = (element, key, value) => {
|
||||
const styleKey = key;
|
||||
element.style[styleKey] = value;
|
||||
};
|
||||
|
||||
let scrollableAncestor = null;
|
||||
let current = canvas.parentElement;
|
||||
while (current && current !== document.body) {
|
||||
if (isScrollable(current)) {
|
||||
scrollableAncestor = current;
|
||||
break;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
const scrollingElement = document.scrollingElement || document.documentElement;
|
||||
const initialRect = canvas.getBoundingClientRect();
|
||||
|
||||
if (scrollableAncestor) {
|
||||
scrollableAncestor.scrollTop = 0;
|
||||
applyStyle(scrollableAncestor, 'overflowY', 'visible');
|
||||
applyStyle(scrollableAncestor, 'height', `${scrollableAncestor.scrollHeight}px`);
|
||||
applyStyle(scrollableAncestor, 'maxHeight', 'none');
|
||||
}
|
||||
|
||||
if (scrollingElement) {
|
||||
scrollingElement.scrollTop = 0;
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const fullHeight = Math.max(
|
||||
Math.ceil(rect.height),
|
||||
Math.ceil(canvas.scrollHeight || 0),
|
||||
Math.ceil(scrollableAncestor?.scrollHeight || 0),
|
||||
Math.ceil(document.documentElement.scrollHeight || 0)
|
||||
);
|
||||
|
||||
return {
|
||||
hadScrollableAncestor: Boolean(scrollableAncestor),
|
||||
initialHeight: Math.ceil(initialRect.height),
|
||||
finalHeight: Math.ceil(rect.height),
|
||||
captureHeight: fullHeight,
|
||||
viewportHeight: Math.min(Math.max(fullHeight, minViewportHeight), maxViewportHeight),
|
||||
viewportWidth: viewportWidth
|
||||
};
|
||||
},
|
||||
{
|
||||
targetSelector: selector,
|
||||
maxViewportHeight: MAX_CAPTURE_VIEWPORT_HEIGHT,
|
||||
minViewportHeight: viewportHeight,
|
||||
viewportWidth: viewportWidth
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function toPdfBuffer(pngBytes, PDFDocument) {
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const image = await pdfDoc.embedPng(pngBytes);
|
||||
const page = pdfDoc.addPage([image.width, image.height]);
|
||||
page.drawImage(image, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image.width,
|
||||
height: image.height
|
||||
});
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
return Buffer.from(pdfBytes);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let browser;
|
||||
let page;
|
||||
const debugLogs = [];
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = args.url;
|
||||
const token = args.token;
|
||||
const output = args.output;
|
||||
if (!url || !token || !output) {
|
||||
throw new Error('url, token and output are required');
|
||||
}
|
||||
|
||||
const width = parsePositiveInteger(args.width ?? '1920', 'width');
|
||||
const height = parsePositiveInteger(args.height ?? '1080', 'height');
|
||||
const waitSeconds = parseWaitSeconds(args['wait-seconds']);
|
||||
const resultFormat = Number.parseInt(args['result-format'] ?? '0', 10);
|
||||
if (![0, 1].includes(resultFormat)) {
|
||||
throw new Error('result-format must be 0 or 1');
|
||||
}
|
||||
|
||||
const [{ chromium }, { PDFDocument }] = await Promise.all([
|
||||
import('playwright'),
|
||||
import('pdf-lib')
|
||||
]);
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH || undefined
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height },
|
||||
deviceScaleFactor: 1,
|
||||
ignoreHTTPSErrors: true,
|
||||
extraHTTPHeaders: {
|
||||
'X-DE-TOKEN': token
|
||||
}
|
||||
});
|
||||
await context.route('**/de2api/**', async route => {
|
||||
const request = route.request();
|
||||
const headers = {
|
||||
...request.headers(),
|
||||
'X-DE-TOKEN': token
|
||||
};
|
||||
const url = request.url();
|
||||
if (url.includes('/de2api/outerParams/getOuterParamsInfo/')) {
|
||||
try {
|
||||
const response = await route.fetch({ headers });
|
||||
if (response.status() < 500) {
|
||||
await route.fulfill({ response });
|
||||
return;
|
||||
}
|
||||
debugLogs.push(`outerParams fallback: ${response.status()} ${url}`);
|
||||
} catch (error) {
|
||||
debugLogs.push(`outerParams fallback error: ${error?.message || String(error)}`);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json;charset=UTF-8',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
outerParamsInfoMap: {},
|
||||
outerParamsInfoBaseMap: {}
|
||||
}
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.continue({ headers });
|
||||
});
|
||||
page = await context.newPage();
|
||||
const selector = '.canvas-container';
|
||||
const timeout = 120000;
|
||||
const pendingRequests = new Set();
|
||||
const trackRequest = request => {
|
||||
const resourceType = request.resourceType();
|
||||
if (!['fetch', 'xhr'].includes(resourceType)) {
|
||||
return false;
|
||||
}
|
||||
return request.url().includes('/de2api/');
|
||||
};
|
||||
page.on('request', request => {
|
||||
if (trackRequest(request)) {
|
||||
pendingRequests.add(request);
|
||||
}
|
||||
});
|
||||
page.on('requestfinished', request => {
|
||||
pendingRequests.delete(request);
|
||||
});
|
||||
page.on('requestfailed', request => {
|
||||
pendingRequests.delete(request);
|
||||
});
|
||||
page.on('response', response => {
|
||||
if (response.status() >= 400 && response.url().includes('/de2api/')) {
|
||||
debugLogs.push(`response ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', error => {
|
||||
debugLogs.push(`pageerror ${error.message}`);
|
||||
});
|
||||
page.on('console', message => {
|
||||
if (message.type() === 'error') {
|
||||
debugLogs.push(`console ${message.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
await page.addInitScript(
|
||||
({ injectedToken, cacheToken, cacheExp, cacheTime }) => {
|
||||
localStorage.setItem('user.token', cacheToken);
|
||||
localStorage.setItem('user.exp', cacheExp);
|
||||
localStorage.setItem('user.time', cacheTime);
|
||||
localStorage.setItem('__de_raw_token__', injectedToken);
|
||||
},
|
||||
{
|
||||
injectedToken: token,
|
||||
cacheToken: buildWsCacheItem(token, now),
|
||||
cacheExp: buildWsCacheItem(now + 3600 * 1000, now),
|
||||
cacheTime: buildWsCacheItem(now, now)
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout });
|
||||
try {
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 });
|
||||
} catch {
|
||||
// Preview pages may keep network connections alive; selector checks below are stricter.
|
||||
}
|
||||
|
||||
const locator = await waitForCanvasReady(page, selector, timeout);
|
||||
const renderState = await waitForRenderSettled(
|
||||
page,
|
||||
selector,
|
||||
timeout,
|
||||
debugLogs,
|
||||
() => pendingRequests.size
|
||||
);
|
||||
const expandedCapture = await expandScrollableCapture(page, selector, width, height);
|
||||
if (expandedCapture?.captureHeight > height) {
|
||||
await page.setViewportSize({
|
||||
width,
|
||||
height: expandedCapture.viewportHeight
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
await waitForRenderSettled(
|
||||
page,
|
||||
selector,
|
||||
timeout,
|
||||
debugLogs,
|
||||
() => pendingRequests.size
|
||||
);
|
||||
}
|
||||
if (waitSeconds > 0) {
|
||||
await page.waitForTimeout(waitSeconds * 1000);
|
||||
}
|
||||
const box = await locator.boundingBox();
|
||||
if (!box || box.width < 1 || box.height < 1) {
|
||||
throw new Error('canvas container is empty');
|
||||
}
|
||||
|
||||
if (resultFormat === 1) {
|
||||
const pngBytes = await locator.screenshot({
|
||||
type: 'png',
|
||||
animations: 'disabled'
|
||||
});
|
||||
const pdfBytes = await toPdfBuffer(pngBytes, PDFDocument);
|
||||
await fs.writeFile(output, pdfBytes);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
format: 'pdf',
|
||||
width: Math.round(box.width),
|
||||
height: Math.round(box.height),
|
||||
renderState,
|
||||
expandedCapture,
|
||||
output
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await locator.screenshot({
|
||||
path: output,
|
||||
type: 'jpeg',
|
||||
quality: 90,
|
||||
animations: 'disabled'
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
format: 'jpeg',
|
||||
width: Math.round(box.width),
|
||||
height: Math.round(box.height),
|
||||
renderState,
|
||||
expandedCapture,
|
||||
output
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ERR_MODULE_NOT_FOUND') {
|
||||
console.error('Missing runtime dependency. Please run `npm install` and `npx playwright install chromium` first.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const diagnostics = page
|
||||
? await collectDiagnostics(page, debugLogs)
|
||||
: null;
|
||||
if (diagnostics) {
|
||||
console.error(`${error && error.stack ? error.stack : String(error)}\nDiagnostics: ${JSON.stringify(diagnostics)}`);
|
||||
} else {
|
||||
console.error(error && error.stack ? error.stack : String(error));
|
||||
}
|
||||
} catch {
|
||||
console.error(error && error.stack ? error.stack : String(error));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import base64
|
||||
import os
|
||||
import requests
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives import padding
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings()
|
||||
|
||||
class DataEaseClient:
|
||||
def __init__(self, base_url, access_key, secret_key):
|
||||
# Separate base URL and API prefix
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_prefix = os.environ.get('DATAEASE_API_PREFIX', '/de2api')
|
||||
self.access_key = access_key
|
||||
self.secret_key = secret_key
|
||||
|
||||
def _get_signature(self, uid, timestamp):
|
||||
src_str = f"{self.access_key}|{uid}|{timestamp}"
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded_data = padder.update(src_str.encode('utf-8')) + padder.finalize()
|
||||
cipher = Cipher(
|
||||
algorithms.AES(self.secret_key.encode('utf-8')),
|
||||
modes.CBC(self.access_key.encode('utf-8')),
|
||||
backend=default_backend()
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
|
||||
return base64.b64encode(ciphertext).decode('utf-8')
|
||||
|
||||
def _get_headers(self):
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
uid = str(uuid.uuid4())
|
||||
signature = self._get_signature(uid, timestamp)
|
||||
claims = {
|
||||
"accessKey": self.access_key,
|
||||
"signature": signature
|
||||
}
|
||||
token = jwt.encode(claims, self.secret_key, algorithm="HS256")
|
||||
if isinstance(token, bytes):
|
||||
token = token.decode('utf-8')
|
||||
|
||||
return {
|
||||
"accessKey": self.access_key,
|
||||
"signature": signature,
|
||||
"x-de-ask-token": token,
|
||||
"timestamp": timestamp,
|
||||
"nonce": uid,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def get(self, path, params=None):
|
||||
url = f"{self.base_url}{self.api_prefix}{path}"
|
||||
response = requests.get(url, headers=self._get_headers(), params=params, verify=False)
|
||||
return response
|
||||
|
||||
def post(self, path, payload=None):
|
||||
url = f"{self.base_url}{self.api_prefix}{path}"
|
||||
response = requests.post(url, headers=self._get_headers(), json=payload, verify=False)
|
||||
return response
|
||||
|
||||
def get_dataset_fields(self, dataset_id):
|
||||
return self.post(f"/datasetField/listByDatasetGroup/{dataset_id}").json()
|
||||
|
||||
def update_publish_status(self, dashboard_id, name, status=1, type='dashboard', mobile_layout=False, active_view_ids=None):
|
||||
url = f"{self.base_url}{self.api_prefix}/dataVisualization/updatePublishStatus"
|
||||
payload = {
|
||||
"id": dashboard_id,
|
||||
"name": name,
|
||||
"mobileLayout": mobile_layout,
|
||||
"activeViewIds": active_view_ids or [],
|
||||
"status": status,
|
||||
"type": type
|
||||
}
|
||||
response = requests.post(url, headers=self._get_headers(), json=payload, verify=False)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
@@ -0,0 +1,158 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
# Add local path for engine import
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from engine import DataEaseChartEngine
|
||||
|
||||
# --- Configuration from Environment ---
|
||||
def load_dotenv():
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
os.environ[key.strip()] = value.strip().strip('"').strip("'")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
ACCESS_KEY = os.environ.get("DATAEASE_ACCESS_KEY")
|
||||
SECRET_KEY = os.environ.get("DATAEASE_SECRET_KEY")
|
||||
BASE_URL = os.environ.get("DATAEASE_BASE_URL")
|
||||
USERNAME = os.environ.get("DATAEASE_USERNAME")
|
||||
PASSWORD = os.environ.get("DATAEASE_PASSWORD")
|
||||
LOGIN_ORIGIN = os.environ.get("DATAEASE_LOGIN_ORIGIN", "0")
|
||||
|
||||
# Screenshot configuration - auto-detect OpenClaw workspace for MEDIA: display
|
||||
def _default_output_dir():
|
||||
"""优先使用 OpenClaw workspace 目录,否则用 skill 本地 output 目录"""
|
||||
# 1. 环境变量显式指定
|
||||
env_dir = os.environ.get("DATAEASE_OUTPUT_DIR")
|
||||
if env_dir:
|
||||
return env_dir
|
||||
# 2. 自动检测 OpenClaw workspace
|
||||
home = os.path.expanduser("~")
|
||||
workspace_dir = os.path.join(home, ".openclaw", "workspace", "dataease-output")
|
||||
if os.path.isdir(os.path.join(home, ".openclaw", "workspace")):
|
||||
os.makedirs(workspace_dir, exist_ok=True)
|
||||
return workspace_dir
|
||||
# 3. 回退到 skill 本地 output
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output")
|
||||
|
||||
OUTPUT_DIR = _default_output_dir()
|
||||
DEFAULT_PIXEL = "2560*1440" # 更高分辨率,图片更清晰
|
||||
|
||||
def capture_dashboard(dashboard_id, output_format="jpeg"):
|
||||
"""调用 capture_dashboard.py 进行截图"""
|
||||
scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
capture_script = os.path.join(scripts_dir, "capture_dashboard.py")
|
||||
|
||||
if not os.path.exists(capture_script):
|
||||
return None, "Screenshot script not found"
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# capture_dashboard.py 使用相同的 BASE_URL(不含 /de2api 后缀)
|
||||
capture_base_url = BASE_URL.rstrip("/")
|
||||
|
||||
# 调用截图脚本
|
||||
result_format = "0" if output_format == "jpeg" else "1" # 0=jpeg, 1=pdf
|
||||
cmd = [
|
||||
"python3", capture_script,
|
||||
"capture",
|
||||
"--resource-id", str(dashboard_id),
|
||||
"--busi-type", "dashboard",
|
||||
"--output-dir", OUTPUT_DIR,
|
||||
"--result-format", result_format,
|
||||
"--base-url", capture_base_url,
|
||||
"--pixel", DEFAULT_PIXEL
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode == 0:
|
||||
output = json.loads(result.stdout)
|
||||
if output.get("ok"):
|
||||
return output.get("saved_file"), None
|
||||
else:
|
||||
return None, output.get("error", "Unknown capture error")
|
||||
else:
|
||||
return None, result.stderr or "Capture failed"
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "Capture timeout"
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
def main():
|
||||
if not BASE_URL:
|
||||
print("Error: Missing required environment variable DATAEASE_BASE_URL.")
|
||||
sys.exit(1)
|
||||
if not ACCESS_KEY and not USERNAME:
|
||||
print("Error: Missing authentication. Please set either:")
|
||||
print(" DATAEASE_ACCESS_KEY + DATAEASE_SECRET_KEY")
|
||||
print(" or DATAEASE_USERNAME + DATAEASE_PASSWORD")
|
||||
sys.exit(1)
|
||||
|
||||
if len(sys.argv) < 5:
|
||||
print("Usage: python3 deploy.py <type> <title> <dataset_name_or_id> <x_fields> <y_fields> [--no-screenshot]")
|
||||
print("Example: python3 deploy.py line 'Skill Test' '电商用户购买行为' '访问平台' '访问次数'")
|
||||
sys.exit(1)
|
||||
|
||||
chart_type = sys.argv[1]
|
||||
title = sys.argv[2]
|
||||
dataset_id = sys.argv[3]
|
||||
x_axis = [f.strip() for f in sys.argv[4].split(',') if f.strip()]
|
||||
y_axis = [f.strip() for f in sys.argv[5].split(',') if f.strip()]
|
||||
|
||||
no_screenshot = "--no-screenshot" in sys.argv
|
||||
|
||||
if ACCESS_KEY and SECRET_KEY:
|
||||
engine = DataEaseChartEngine(BASE_URL, ACCESS_KEY, SECRET_KEY)
|
||||
else:
|
||||
engine = DataEaseChartEngine(BASE_URL, USERNAME, PASSWORD, auth_mode="password")
|
||||
|
||||
try:
|
||||
did, url = engine.deploy(chart_type, title, dataset_id, x_axis, y_axis)
|
||||
print(f"\n✅ Successfully deployed!")
|
||||
print(f"Dashboard ID: {did}")
|
||||
print(f"URL: {url}")
|
||||
|
||||
# 自动截图
|
||||
if not no_screenshot:
|
||||
print("\n📸 Capturing screenshot...")
|
||||
screenshot_path, error = capture_dashboard(did)
|
||||
if screenshot_path:
|
||||
print(f"Screenshot saved: {screenshot_path}")
|
||||
# 输出 JSON 结果供 Agent 解析
|
||||
result = {
|
||||
"ok": True,
|
||||
"dashboard_id": str(did),
|
||||
"url": url,
|
||||
"screenshot": screenshot_path,
|
||||
"title": title
|
||||
}
|
||||
print(f"\n__RESULT_JSON__\n{json.dumps(result, ensure_ascii=False)}\n__END_JSON__")
|
||||
else:
|
||||
print(f"⚠️ Screenshot failed: {error}")
|
||||
result = {
|
||||
"ok": True,
|
||||
"dashboard_id": str(did),
|
||||
"url": url,
|
||||
"screenshot": None,
|
||||
"screenshot_error": error,
|
||||
"title": title
|
||||
}
|
||||
print(f"\n__RESULT_JSON__\n{json.dumps(result, ensure_ascii=False)}\n__END_JSON__")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Deployment failed: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,326 @@
|
||||
import json, re, time, random, os, sys, requests
|
||||
import urllib3
|
||||
urllib3.disable_warnings()
|
||||
|
||||
# Local SDK client
|
||||
from client import DataEaseClient
|
||||
|
||||
class DataEaseChartEngine:
|
||||
def __init__(self, base_url, ak_or_user, sk_or_password, auth_mode=None):
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.api_prefix = os.environ.get('DATAEASE_API_PREFIX', '/de2api')
|
||||
self.auth_mode = auth_mode or 'aksk'
|
||||
|
||||
if self.auth_mode == 'password':
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("inspect_data", os.path.join(os.path.dirname(__file__), "inspect_data.py"))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
x_de_token = mod.login_with_password(base_url, self.api_prefix, ak_or_user, sk_or_password)
|
||||
self.headers = mod.get_headers_token(x_de_token)
|
||||
self.client = None
|
||||
else:
|
||||
self.client = DataEaseClient(base_url, ak_or_user, sk_or_password)
|
||||
self.headers = None
|
||||
|
||||
def _get_headers(self):
|
||||
if self.headers:
|
||||
return self.headers
|
||||
return self._get_headers()
|
||||
|
||||
def _post(self, path, payload=None):
|
||||
url = f"{self.base_url}{self.api_prefix}{path}"
|
||||
return requests.post(url, headers=self._get_headers(), json=payload, verify=False, timeout=30)
|
||||
|
||||
def _get(self, path, params=None):
|
||||
url = f"{self.base_url}{self.api_prefix}{path}"
|
||||
return requests.get(url, headers=self._get_headers(), params=params, verify=False, timeout=30)
|
||||
|
||||
def resolve_dataset_id(self, name_or_id):
|
||||
"""Resolve dataset name to ID using /datasetTree/tree if needed"""
|
||||
if str(name_or_id).isdigit() and len(str(name_or_id)) > 10:
|
||||
return name_or_id
|
||||
|
||||
url = f"{self.base_url}{self.api_prefix}/datasetTree/tree"
|
||||
headers = self._get_headers()
|
||||
resp = requests.post(url, headers=headers, json={"busiFlag": "dataset"}, verify=False, timeout=30)
|
||||
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f"Failed to fetch dataset tree: {resp.text}")
|
||||
|
||||
nodes = resp.json().get('data', [])
|
||||
|
||||
def find_in_tree(items, target_name):
|
||||
for item in items:
|
||||
if item.get('name') == target_name:
|
||||
return item.get('id')
|
||||
children = item.get('children', [])
|
||||
if children:
|
||||
found = find_in_tree(children, target_name)
|
||||
if found: return found
|
||||
return None
|
||||
|
||||
dataset_id = find_in_tree(nodes, name_or_id)
|
||||
if not dataset_id:
|
||||
raise ValueError(f"Dataset '{name_or_id}' not found in DataEase")
|
||||
|
||||
return dataset_id
|
||||
|
||||
def get_dataset_ctx(self, dataset_name_or_id, x_names, y_names):
|
||||
"""Fetch dataset metadata and build rendering context"""
|
||||
dataset_id = self.resolve_dataset_id(dataset_name_or_id)
|
||||
|
||||
# DataEase v2 often uses datasetGroup for the tree structure
|
||||
url = f"{self.base_url}{self.api_prefix}/datasetTree/details/{dataset_id}"
|
||||
headers = self._get_headers()
|
||||
resp = requests.get(url, headers=headers, verify=False, timeout=30)
|
||||
|
||||
if resp.status_code != 200:
|
||||
# Fallback to the original method if the specific tree detail endpoint fails
|
||||
resp = self._post(f"/datasetField/listByDatasetGroup/{dataset_id}")
|
||||
fields = resp.json().get('data', [])
|
||||
else:
|
||||
data = resp.json().get('data', {})
|
||||
fields = data.get('allFields', [])
|
||||
if not fields:
|
||||
# Try to get from datasetField/listByDatasetGroup if tree details is empty
|
||||
resp = self._post(f"/datasetField/listByDatasetGroup/{dataset_id}")
|
||||
fields = resp.json().get('data', [])
|
||||
|
||||
if not fields:
|
||||
raise ValueError(f"No fields found for dataset {dataset_id}")
|
||||
|
||||
f_map = {f['name']: f for f in fields}
|
||||
|
||||
# Get table/datasource IDs from the first field (they should be common)
|
||||
datasource_id = fields[0].get('datasourceId', "")
|
||||
table_id = fields[0].get('datasetTableId', "")
|
||||
|
||||
ctx = {
|
||||
"DATASET_GROUP_ID": dataset_id,
|
||||
"DATASOURCE_ID": datasource_id,
|
||||
"DATASET_TABLE_ID": table_id,
|
||||
}
|
||||
|
||||
# Map axis fields (supports multi-measure if needed)
|
||||
for i, name in enumerate(x_names):
|
||||
suffix = "" if i == 0 else str(i+1)
|
||||
f = f_map.get(name)
|
||||
if not f: raise ValueError(f"X-Axis Field '{name}' not found")
|
||||
ctx[f"XAXIS{suffix}_FIELD_ID"] = f['id']
|
||||
ctx[f"XAXIS{suffix}_DE_NAME"] = f['dataeaseName']
|
||||
|
||||
for i, name in enumerate(y_names):
|
||||
suffix = "" if i == 0 else str(i+1)
|
||||
f = f_map.get(name)
|
||||
if not f: raise ValueError(f"Y-Axis Field '{name}' not found")
|
||||
ctx[f"YAXIS{suffix}_FIELD_ID"] = f['id']
|
||||
ctx[f"YAXIS{suffix}_DE_NAME"] = f['dataeaseName']
|
||||
ctx[f"YAXIS{suffix}_SERIES_ID"] = f"{f['id']}-yAxis"
|
||||
|
||||
# Additional keys for deep parameterization
|
||||
if i == 0:
|
||||
ctx["YAXIS_FIELD_ID"] = f['id']
|
||||
ctx["YAXIS_DE_NAME"] = f['dataeaseName']
|
||||
ctx["YAXIS_SERIES_ID"] = f"{f['id']}-yAxis"
|
||||
elif i == 1:
|
||||
ctx["YAXIS2_FIELD_ID"] = f['id']
|
||||
ctx["YAXIS2_DE_NAME"] = f['dataeaseName']
|
||||
ctx["YAXIS2_SERIES_ID"] = f"{f['id']}-yAxis"
|
||||
|
||||
return ctx
|
||||
|
||||
def deploy(self, chart_type, title, dataset_id, x_names, y_names, layout=None):
|
||||
# 1. Directory standardization
|
||||
# Use relative path from this script (scripts/engine.py) to templates/
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
tpl_dir = os.path.join(base_dir, "..", "templates", f"chart_{chart_type}")
|
||||
|
||||
if not os.path.exists(tpl_dir):
|
||||
raise FileNotFoundError(f"Template directory {tpl_dir} not found")
|
||||
|
||||
with open(os.path.join(tpl_dir, "template.j2")) as f: template_str = f.read()
|
||||
with open(os.path.join(tpl_dir, "params.json")) as f:
|
||||
raw_params = json.load(f)
|
||||
|
||||
# 2. Build Context (Flatten params + dynamic IDs)
|
||||
ctx = {}
|
||||
# Flatten nested params.json
|
||||
def flatten_dict(d):
|
||||
items = {}
|
||||
for k, v in d.items():
|
||||
if isinstance(v, dict):
|
||||
items.update(flatten_dict(v))
|
||||
elif not k.startswith("_"):
|
||||
items[k] = v
|
||||
return items
|
||||
|
||||
ctx.update(flatten_dict(raw_params))
|
||||
ctx.update(self.get_dataset_ctx(dataset_id, x_names, y_names))
|
||||
|
||||
# 3. Runtime Randomization
|
||||
view_id, content_id = self.rand_id(), self.rand_id()
|
||||
ctx.update({"VIEW_ID": view_id, "CONTENT_ID": content_id, "SCENE_ID": "0"})
|
||||
|
||||
# 4. Thorough Parameterization (Global substitution)
|
||||
def rep(m):
|
||||
k = m.group(1).strip()
|
||||
if k in ctx:
|
||||
return str(ctx[k])
|
||||
return m.group(0) # Keep placeholder if not found
|
||||
|
||||
rendered = re.sub(r"\{\{\s*(\w+)\s*\}\}", rep, template_str)
|
||||
payload = json.loads(rendered)
|
||||
|
||||
# 5. Type Closure & Payload Finalization
|
||||
board_name = f"{title}_{int(time.time())}"
|
||||
|
||||
# Override title in canvasViewInfo
|
||||
if "canvasViewInfo" in payload and view_id in payload["canvasViewInfo"]:
|
||||
view_info = payload["canvasViewInfo"][view_id]
|
||||
view_info["title"] = title
|
||||
|
||||
# Deep update all field names (xAxis, yAxis, labels, tooltips, etc.)
|
||||
def deep_update_field_names(obj, target_id, new_name):
|
||||
if isinstance(obj, dict):
|
||||
if str(obj.get("id")) == str(target_id):
|
||||
obj["name"] = new_name
|
||||
if "description" in obj: obj["description"] = new_name
|
||||
if "originName" in obj: obj["originName"] = new_name
|
||||
# 核心修复:DataEase V2 在某些场景下会回退到 dbFieldName,强制设为 null 或同步
|
||||
if "dbFieldName" in obj: obj["dbFieldName"] = None
|
||||
|
||||
# 特殊修复:处理 DataEase 特有的标签和提示框拼接字段 (如 "访问平台(求和)")
|
||||
# 我们无法预知原始模板里的名字,所以这里尝试匹配常见的拼接模式
|
||||
if "optionLabel" in obj:
|
||||
obj["optionLabel"] = new_name + (obj["optionLabel"].partition("(")[1] + obj["optionLabel"].partition("(")[2] if "(" in obj["optionLabel"] else "")
|
||||
if "optionShowName" in obj:
|
||||
obj["optionShowName"] = new_name + (obj["optionShowName"].partition("(")[1] + obj["optionShowName"].partition("(")[2] if "(" in obj["optionShowName"] else "")
|
||||
|
||||
for v in obj.values():
|
||||
deep_update_field_names(v, target_id, new_name)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
deep_update_field_names(item, target_id, new_name)
|
||||
|
||||
# Apply updates for X and Y axis fields
|
||||
# We use the field IDs obtained from dataset metadata to find and replace
|
||||
dataset_ctx = self.get_dataset_ctx(dataset_id, x_names, y_names)
|
||||
|
||||
# Pre-scan templates/params.json for the old names to ensure replacement works
|
||||
# or use common default names from templates
|
||||
old_names_to_clear = ["访问次数", "访问平台", "浏览量"]
|
||||
|
||||
for i, x_name in enumerate(x_names):
|
||||
f_id = dataset_ctx.get(f"XAXIS{'' if i==0 else i+1}_FIELD_ID")
|
||||
if f_id:
|
||||
deep_update_field_names(view_info, f_id, x_name)
|
||||
|
||||
for i, y_name in enumerate(y_names):
|
||||
f_id = dataset_ctx.get(f"YAXIS{'' if i==0 else i+1}_FIELD_ID")
|
||||
if f_id:
|
||||
deep_update_field_names(view_info, f_id, y_name)
|
||||
|
||||
# 最终保底:如果还有残留的“访问次数”或“访问平台”,强行全局替换
|
||||
def brute_force_replace(obj, search_list, replace_list):
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, str):
|
||||
for s, r in zip(search_list, replace_list):
|
||||
if s in v:
|
||||
obj[k] = v.replace(s, r)
|
||||
else:
|
||||
brute_force_replace(v, search_list, replace_list)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
brute_force_replace(item, search_list, replace_list)
|
||||
|
||||
# Use primary X/Y names for brute force fallback
|
||||
if x_names and y_names:
|
||||
brute_force_replace(view_info, ["访问平台", "访问次数", "浏览量"], [x_names[0], y_names[0], y_names[0]])
|
||||
|
||||
# If layout is provided, override it in componentData
|
||||
if "componentData" in payload:
|
||||
components = json.loads(payload["componentData"])
|
||||
if components:
|
||||
target = components[0]
|
||||
# Update component name to title
|
||||
target["name"] = title
|
||||
target["label"] = title
|
||||
|
||||
# 单图表模式:充满整个画布
|
||||
if layout is None and len(components) == 1:
|
||||
# DataEase 单图表网格 72x36,让图表充满整个画布
|
||||
target["x"] = 1
|
||||
target["y"] = 1
|
||||
target["sizeX"] = 72
|
||||
target["sizeY"] = 36
|
||||
if "style" not in target:
|
||||
target["style"] = {}
|
||||
target["style"]["width"] = 1920
|
||||
target["style"]["height"] = 1080
|
||||
target["style"]["left"] = 0
|
||||
target["style"]["top"] = 0
|
||||
elif layout:
|
||||
if "x" in layout: target["x"] = layout["x"]
|
||||
if "y" in layout: target["y"] = layout["y"]
|
||||
if "sizeX" in layout: target["sizeX"] = layout["sizeX"]
|
||||
if "sizeY" in layout: target["sizeY"] = layout["sizeY"]
|
||||
if "style" not in target: target["style"] = {}
|
||||
if "width" in layout: target["style"]["width"] = layout["width"]
|
||||
if "height" in layout: target["style"]["height"] = layout["height"]
|
||||
if "left" in layout: target["style"]["left"] = layout["left"]
|
||||
if "top" in layout: target["style"]["top"] = layout["top"]
|
||||
payload["componentData"] = json.dumps(components, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
# Ensure canvasStyleData is also compact
|
||||
if "canvasStyleData" in payload:
|
||||
style = json.loads(payload["canvasStyleData"])
|
||||
payload["canvasStyleData"] = json.dumps(style, separators=(',', ':'), ensure_ascii=False)
|
||||
|
||||
payload.update({
|
||||
"id": None,
|
||||
"name": board_name,
|
||||
"type": "dashboard",
|
||||
"status": 0,
|
||||
"dataState": "ready",
|
||||
"selfWatermarkStatus": True,
|
||||
"checkVersion": "2.10.20",
|
||||
"pid": "0",
|
||||
"mobileLayout": False
|
||||
})
|
||||
|
||||
headers = self._get_headers()
|
||||
print(f"Deploying chart '{title}' (Type: {chart_type})...")
|
||||
|
||||
save_r = requests.post(
|
||||
f"{self.base_url}{self.api_prefix}/dataVisualization/saveCanvas",
|
||||
headers=headers, json=payload, verify=False, timeout=30
|
||||
)
|
||||
|
||||
if save_r.status_code != 200:
|
||||
raise Exception(f"saveCanvas failed: {save_r.text}")
|
||||
|
||||
dashboard_id = str(save_r.json()["data"])
|
||||
|
||||
# 6. Publish
|
||||
requests.post(
|
||||
f"{self.base_url}{self.api_prefix}/dataVisualization/updatePublishStatus",
|
||||
headers=headers, verify=False, timeout=30,
|
||||
json={
|
||||
"id": dashboard_id,
|
||||
"name": board_name,
|
||||
"activeViewIds": [view_id],
|
||||
"status": 1,
|
||||
"type": "dashboard",
|
||||
"mobileLayout": False
|
||||
}
|
||||
)
|
||||
|
||||
# Generate preview URL
|
||||
return dashboard_id, f"{self.base_url}/#/preview?dvId={dashboard_id}&dvType=dashboard&ignoreParams=true"
|
||||
|
||||
@staticmethod
|
||||
def rand_id():
|
||||
return str(int(time.time() * 1000) + random.randint(1000, 9999))
|
||||
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DataEase 数据集探索脚本
|
||||
支持 AK/SK 和密码登录两种认证方式
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
import subprocess
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
import uuid
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
# Add scripts to path for engine import
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def load_dotenv():
|
||||
"""加载 .env 文件"""
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path, encoding="utf8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
os.environ[key.strip()] = value.strip().strip('"').strip("'")
|
||||
|
||||
|
||||
def base64url(raw):
|
||||
"""Base64 URL-safe 编码"""
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def sign_jwt(payload, secret_key):
|
||||
"""使用 HMAC-SHA256 签名 JWT"""
|
||||
header = {"alg": "HS256", "typ": "JWT"}
|
||||
header_part = base64url(json.dumps(header, separators=(",", ":")).encode("utf-8"))
|
||||
payload_part = base64url(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
|
||||
signing_input = f"{header_part}.{payload_part}".encode("ascii")
|
||||
signature = hmac.new(secret_key.encode("utf-8"), signing_input, hashlib.sha256).digest()
|
||||
return f"{header_part}.{payload_part}.{base64url(signature)}"
|
||||
|
||||
|
||||
def aes_cipher_name(secret_key):
|
||||
"""根据密钥长度返回 AES 加密算法名称"""
|
||||
length = len(secret_key.encode("utf-8"))
|
||||
if length == 16:
|
||||
return "aes-128-cbc"
|
||||
if length == 24:
|
||||
return "aes-192-cbc"
|
||||
if length == 32:
|
||||
return "aes-256-cbc"
|
||||
raise ValueError("Secret Key 长度必须是 16、24 或 32 字节")
|
||||
|
||||
|
||||
def aes_encrypt(plain_text, secret_key, iv):
|
||||
"""AES 加密"""
|
||||
if shutil.which("openssl") is None:
|
||||
raise RuntimeError("当前环境缺少 openssl 命令,无法生成鉴权签名")
|
||||
if len(iv.encode("utf-8")) != 16:
|
||||
raise ValueError("Access Key 长度必须是 16 字节,才能作为 AES IV")
|
||||
|
||||
cmd = [
|
||||
"openssl", "enc", f"-{aes_cipher_name(secret_key)}",
|
||||
"-base64", "-A", "-nosalt",
|
||||
"-K", secret_key.encode("utf-8").hex(),
|
||||
"-iv", iv.encode("utf-8").hex(),
|
||||
]
|
||||
proc = subprocess.run(cmd, input=plain_text.encode("utf-8"), capture_output=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr.decode("utf-8", errors="replace").strip()
|
||||
raise RuntimeError(stderr or "openssl 加密失败")
|
||||
return proc.stdout.decode("utf-8").strip()
|
||||
|
||||
|
||||
def build_ask_auth(access_key, secret_key):
|
||||
"""构建 ASK 认证信息"""
|
||||
source = f"{access_key}|{uuid.uuid4()}|{int(time.time() * 1000)}"
|
||||
signature = aes_encrypt(source, secret_key, access_key)
|
||||
token = sign_jwt({"accessKey": access_key, "signature": signature}, secret_key)
|
||||
return {
|
||||
"access_key": access_key,
|
||||
"signature": signature,
|
||||
"x_de_ask_token": token,
|
||||
}
|
||||
|
||||
|
||||
def get_headers_ask(ask_auth):
|
||||
"""构建 ASK 认证请求头"""
|
||||
return {
|
||||
"Accept": "application/json;charset=UTF-8",
|
||||
"Content-Type": "application/json",
|
||||
"accessKey": ask_auth["access_key"],
|
||||
"signature": ask_auth["signature"],
|
||||
"X-DE-ASK-TOKEN": ask_auth["x_de_ask_token"],
|
||||
}
|
||||
|
||||
|
||||
def fetch_dekey(base_url, api_prefix):
|
||||
"""获取 dekey 用于密码登录"""
|
||||
url = f"{base_url.rstrip('/')}{api_prefix}/dekey"
|
||||
request = Request(url, headers={"Accept": "application/json;charset=UTF-8"}, method="GET")
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, str) or not data:
|
||||
raise ValueError("dekey 接口未返回有效字符串")
|
||||
return data
|
||||
except HTTPError as err:
|
||||
raise RuntimeError(f"获取 dekey 失败: {err.code}")
|
||||
|
||||
|
||||
def split_dekey(dekey):
|
||||
"""拆分 dekey"""
|
||||
separator = base64.urlsafe_b64encode(b"-pk_separator-").decode("ascii")
|
||||
if separator and separator in dekey:
|
||||
parts = dekey.split(separator, 1)
|
||||
if len(parts) == 2 and parts[0] and parts[1]:
|
||||
return parts[0], parts[1]
|
||||
raise ValueError("dekey 格式不符合预期")
|
||||
|
||||
|
||||
def format_public_key(public_key):
|
||||
"""格式化公钥"""
|
||||
body = "\n".join(public_key[index:index + 64] for index in range(0, len(public_key), 64))
|
||||
return f"-----BEGIN PUBLIC KEY-----\n{body}\n-----END PUBLIC KEY-----\n"
|
||||
|
||||
# fixed by xuhuanqing
|
||||
'''
|
||||
def rsa_encrypt(plain_text, public_key):
|
||||
"""RSA 加密"""
|
||||
import tempfile
|
||||
if shutil.which("openssl") is None:
|
||||
raise RuntimeError("当前环境缺少 openssl 命令")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dataease-pubkey-") as tmpdir:
|
||||
key_path = Path(tmpdir) / "public.pem"
|
||||
key_path.write_text(format_public_key(public_key), encoding="utf-8")
|
||||
proc = subprocess.run(
|
||||
["openssl", "pkeyutl", "-encrypt", "-pubin", "-inkey", str(key_path)],
|
||||
input=plain_text.encode("utf-8"),
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(proc.stderr.decode("utf-8", errors="replace").strip() or "RSA 加密失败")
|
||||
return base64.b64encode(proc.stdout).decode("ascii")
|
||||
'''
|
||||
|
||||
def rsa_encrypt(plain_text, public_key):
|
||||
"""RSA加密,兼容OpenSSL pkeyutl参数规范,修复no private key报错"""
|
||||
import tempfile
|
||||
import subprocess
|
||||
import base64
|
||||
from shutil import which
|
||||
from pathlib import Path
|
||||
|
||||
if which("openssl") is None:
|
||||
raise RuntimeError("当前环境缺少 openssl 命令")
|
||||
|
||||
# 格式化标准PEM公钥
|
||||
def format_public_key(pub_raw):
|
||||
body = "\n".join(pub_raw[index:index + 64] for index in range(0, len(pub_raw), 64))
|
||||
return f"-----BEGIN PUBLIC KEY-----\n{body}\n-----END PUBLIC KEY-----\n"
|
||||
|
||||
pub_pem = format_public_key(public_key)
|
||||
# 创建临时公钥文件
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False, encoding="utf-8") as tmp_key:
|
||||
tmp_key.write(pub_pem)
|
||||
tmp_key_path = tmp_key.name
|
||||
try:
|
||||
# 标准规范参数:-inkey 指定公钥文件、-pubin 标识文件类型为公钥
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"openssl", "pkeyutl",
|
||||
"-encrypt",
|
||||
"-pubin",
|
||||
"-inkey", tmp_key_path
|
||||
],
|
||||
input=plain_text.encode("utf-8"),
|
||||
capture_output=True,
|
||||
check=False
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
err_msg = proc.stderr.decode("utf-8", errors="replace").strip()
|
||||
raise RuntimeError(f"RSA加密失败: {err_msg}")
|
||||
return base64.b64encode(proc.stdout).decode("ascii")
|
||||
finally:
|
||||
# 执行完毕强制删除临时密钥文件
|
||||
Path(tmp_key_path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def login_with_password(base_url, api_prefix, username, password):
|
||||
"""使用密码登录获取 x-de-token"""
|
||||
dekey = fetch_dekey(base_url, api_prefix)
|
||||
encrypted_pk, aes_key_str = split_dekey(dekey)
|
||||
|
||||
# 解密获取公钥
|
||||
cmd = [
|
||||
"openssl", "enc", f"-{aes_cipher_name(aes_key_str)}", "-d",
|
||||
"-base64", "-A", "-nosalt",
|
||||
"-K", aes_key_str.encode("utf-8").hex(),
|
||||
"-iv", b"0000000000000000".hex(),
|
||||
]
|
||||
proc = subprocess.run(cmd, input=encrypted_pk.encode("utf-8"), capture_output=True, check=False)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError("解密 dekey 失败")
|
||||
public_key = proc.stdout.decode("utf-8").strip()
|
||||
|
||||
# RSA 加密用户名和密码
|
||||
# fixed by xuhuanqing
|
||||
# encrypted_name = rsa_encrypt(username, public_key)
|
||||
# encrypted_pwd = rsa_encrypt(password, public_key)
|
||||
encrypted_name = rsa_encrypt(username, public_key)
|
||||
encrypted_pwd = rsa_encrypt(password, public_key)
|
||||
|
||||
login_origin = int(os.environ.get("DATAEASE_LOGIN_ORIGIN", "0"))
|
||||
|
||||
payload = {
|
||||
"name": encrypted_name,
|
||||
"pwd": encrypted_pwd,
|
||||
"origin": login_origin,
|
||||
}
|
||||
|
||||
url = f"{base_url.rstrip('/')}{api_prefix}/login/localLogin"
|
||||
request = Request(
|
||||
url,
|
||||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
if result.get("code") not in (None, 0):
|
||||
raise RuntimeError(f"登录失败: {result.get('msg', '未知错误')}")
|
||||
# fixed by xuhuanqing
|
||||
# return result.get("data")
|
||||
return result.get("data")["token"]
|
||||
except HTTPError as err:
|
||||
body = err.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"登录请求失败: {err.code} - {body}")
|
||||
|
||||
|
||||
def get_headers_token(x_de_token):
|
||||
"""构建 Token 认证请求头"""
|
||||
return {
|
||||
"Accept": "application/json;charset=UTF-8",
|
||||
"Content-Type": "application/json",
|
||||
"X-DE-TOKEN": x_de_token,
|
||||
}
|
||||
|
||||
|
||||
def make_request(url, headers, payload=None, method="POST"):
|
||||
"""发送 HTTP 请求"""
|
||||
data = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as err:
|
||||
body = err.read().decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"请求失败: {err.code} - {body}")
|
||||
|
||||
|
||||
def list_datasets(base_url, api_prefix, headers):
|
||||
"""列出所有数据集"""
|
||||
url = f"{base_url.rstrip('/')}{api_prefix}/datasetTree/tree"
|
||||
payload = {"busiFlag": "dataset"}
|
||||
result = make_request(url, headers, payload)
|
||||
|
||||
nodes = result.get('data', [])
|
||||
datasets = []
|
||||
|
||||
def collect_leaf(items):
|
||||
for item in items:
|
||||
if item.get('leaf'):
|
||||
datasets.append({"name": item.get('name'), "id": item.get('id')})
|
||||
children = item.get('children', [])
|
||||
if children:
|
||||
collect_leaf(children)
|
||||
|
||||
collect_leaf(nodes)
|
||||
return datasets
|
||||
|
||||
|
||||
def get_dataset_fields(base_url, api_prefix, headers, dataset_id):
|
||||
"""获取数据集字段"""
|
||||
# 先尝试详情接口
|
||||
url = f"{base_url.rstrip('/')}{api_prefix}/datasetTree/details/{dataset_id}"
|
||||
request = Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
fields = result.get('data', {}).get('allFields', [])
|
||||
if fields:
|
||||
return fields
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退到字段列表接口
|
||||
url = f"{base_url.rstrip('/')}{api_prefix}/datasetField/listByDatasetGroup/{dataset_id}"
|
||||
result = make_request(url, headers, method="POST")
|
||||
return result.get('data', [])
|
||||
|
||||
|
||||
def resolve_dataset_id(base_url, api_prefix, headers, name_or_id):
|
||||
"""解析数据集名称为 ID"""
|
||||
if str(name_or_id).isdigit() and len(str(name_or_id)) > 10:
|
||||
return name_or_id
|
||||
|
||||
datasets = list_datasets(base_url, api_prefix, headers)
|
||||
for ds in datasets:
|
||||
if ds['name'] == name_or_id:
|
||||
return ds['id']
|
||||
|
||||
raise ValueError(f"未找到数据集: {name_or_id}")
|
||||
|
||||
|
||||
def main():
|
||||
load_dotenv()
|
||||
|
||||
base_url = os.environ.get("DATAEASE_BASE_URL", "")
|
||||
api_prefix = os.environ.get("DATAEASE_API_PREFIX", "/de2api")
|
||||
access_key = os.environ.get("DATAEASE_ACCESS_KEY", "")
|
||||
secret_key = os.environ.get("DATAEASE_SECRET_KEY", "")
|
||||
username = os.environ.get("DATAEASE_USERNAME", "")
|
||||
password = os.environ.get("DATAEASE_PASSWORD", "")
|
||||
|
||||
if not base_url:
|
||||
print("Error: 请设置 DATAEASE_BASE_URL")
|
||||
sys.exit(1)
|
||||
|
||||
parser = argparse.ArgumentParser(description="探索 DataEase 数据集和字段")
|
||||
parser.add_argument("--list-datasets", action="store_true", help="列出所有数据集")
|
||||
parser.add_argument("--dataset", type=str, help="查看指定数据集的字段")
|
||||
args = parser.parse_args()
|
||||
|
||||
# 认证:优先 AK/SK,否则用密码登录
|
||||
try:
|
||||
if access_key and secret_key:
|
||||
ask_auth = build_ask_auth(access_key, secret_key)
|
||||
headers = get_headers_ask(ask_auth)
|
||||
auth_mode = "AK/SK"
|
||||
elif username and password:
|
||||
x_de_token = login_with_password(base_url, api_prefix, username, password)
|
||||
headers = get_headers_token(x_de_token)
|
||||
auth_mode = "密码登录"
|
||||
else:
|
||||
print("Error: 请配置 AK/SK (DATAEASE_ACCESS_KEY + DATAEASE_SECRET_KEY) 或用户名密码 (DATAEASE_USERNAME + DATAEASE_PASSWORD)")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"认证失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if args.list_datasets:
|
||||
try:
|
||||
datasets = list_datasets(base_url, api_prefix, headers)
|
||||
print(json.dumps(datasets, ensure_ascii=False, indent=2))
|
||||
except Exception as e:
|
||||
print(f"获取数据集列表失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
elif args.dataset:
|
||||
try:
|
||||
dataset_id = resolve_dataset_id(base_url, api_prefix, headers, args.dataset)
|
||||
fields = get_dataset_fields(base_url, api_prefix, headers, dataset_id)
|
||||
result = [
|
||||
{"name": f['name'], "id": f['id'], "type": f.get('deType'), "dataeaseName": f.get('dataeaseName')}
|
||||
for f in fields
|
||||
]
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
except Exception as e:
|
||||
print(f"获取数据集字段失败: {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,148 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
# Add local path for engine import
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from multi_engine import MultiDataEaseChartEngine
|
||||
|
||||
# --- Configuration from Environment ---
|
||||
def load_dotenv():
|
||||
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env")
|
||||
if os.path.exists(env_path):
|
||||
with open(env_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
os.environ[key.strip()] = value.strip().strip('"').strip("'")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
ACCESS_KEY = os.environ.get("DATAEASE_ACCESS_KEY")
|
||||
SECRET_KEY = os.environ.get("DATAEASE_SECRET_KEY")
|
||||
BASE_URL = os.environ.get("DATAEASE_BASE_URL")
|
||||
|
||||
# Screenshot configuration - auto-detect OpenClaw workspace for MEDIA: display
|
||||
def _default_output_dir():
|
||||
"""优先使用 OpenClaw workspace 目录,否则用 skill 本地 output 目录"""
|
||||
env_dir = os.environ.get("DATAEASE_OUTPUT_DIR")
|
||||
if env_dir:
|
||||
return env_dir
|
||||
home = os.path.expanduser("~")
|
||||
workspace_dir = os.path.join(home, ".openclaw", "workspace", "dataease-output")
|
||||
if os.path.isdir(os.path.join(home, ".openclaw", "workspace")):
|
||||
os.makedirs(workspace_dir, exist_ok=True)
|
||||
return workspace_dir
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "output")
|
||||
|
||||
OUTPUT_DIR = _default_output_dir()
|
||||
DEFAULT_PIXEL = "2560*1440" # 更高分辨率,图片更清晰
|
||||
|
||||
def capture_dashboard(dashboard_id, output_format="jpeg"):
|
||||
"""调用 capture_dashboard.py 进行截图"""
|
||||
scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
capture_script = os.path.join(scripts_dir, "capture_dashboard.py")
|
||||
|
||||
if not os.path.exists(capture_script):
|
||||
return None, "Screenshot script not found"
|
||||
|
||||
# 确保输出目录存在
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# capture_dashboard.py 使用相同的 BASE_URL(不含 /de2api 后缀)
|
||||
capture_base_url = BASE_URL.rstrip("/")
|
||||
|
||||
# 调用截图脚本
|
||||
result_format = "0" if output_format == "jpeg" else "1" # 0=jpeg, 1=pdf
|
||||
cmd = [
|
||||
"python3", capture_script,
|
||||
"capture",
|
||||
"--resource-id", str(dashboard_id),
|
||||
"--busi-type", "dashboard",
|
||||
"--output-dir", OUTPUT_DIR,
|
||||
"--result-format", result_format,
|
||||
"--base-url", capture_base_url,
|
||||
"--pixel", DEFAULT_PIXEL
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode == 0:
|
||||
output = json.loads(result.stdout)
|
||||
if output.get("ok"):
|
||||
return output.get("saved_file"), None
|
||||
else:
|
||||
return None, output.get("error", "Unknown capture error")
|
||||
else:
|
||||
return None, result.stderr or "Capture failed"
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "Capture timeout"
|
||||
except Exception as e:
|
||||
return None, str(e)
|
||||
|
||||
def main():
|
||||
if not all([ACCESS_KEY, SECRET_KEY, BASE_URL]):
|
||||
print("Error: Missing required environment variables.")
|
||||
sys.exit(1)
|
||||
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python3 multi_deploy.py <dashboard_title> <charts_json_config> [--no-screenshot]")
|
||||
print("Example: python3 multi_deploy.py '综合分析' '[{\"type\":\"bar\",\"dataset_name\":\"ds1\",\"x_axis\":[\"f1\"],\"y_axis\":[\"f2\"]}]'")
|
||||
sys.exit(1)
|
||||
|
||||
title = sys.argv[1]
|
||||
try:
|
||||
charts_config = json.loads(sys.argv[2])
|
||||
except Exception as e:
|
||||
print(f"Error parsing JSON config: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 检查是否禁用截图
|
||||
no_screenshot = "--no-screenshot" in sys.argv
|
||||
|
||||
engine = MultiDataEaseChartEngine(BASE_URL, ACCESS_KEY, SECRET_KEY)
|
||||
|
||||
try:
|
||||
did, url = engine.deploy_multi(title, charts_config)
|
||||
print(f"\n✅ Successfully deployed multi-chart dashboard!")
|
||||
print(f"Dashboard ID: {did}")
|
||||
print(f"URL: {url}")
|
||||
|
||||
# 自动截图
|
||||
if not no_screenshot:
|
||||
print("\n📸 Capturing screenshot...")
|
||||
screenshot_path, error = capture_dashboard(did)
|
||||
if screenshot_path:
|
||||
print(f"Screenshot saved: {screenshot_path}")
|
||||
# 输出 JSON 结果供 Agent 解析
|
||||
result = {
|
||||
"ok": True,
|
||||
"dashboard_id": str(did),
|
||||
"url": url,
|
||||
"screenshot": screenshot_path,
|
||||
"title": title,
|
||||
"charts_count": len(charts_config)
|
||||
}
|
||||
print(f"\n__RESULT_JSON__\n{json.dumps(result, ensure_ascii=False)}\n__END_JSON__")
|
||||
else:
|
||||
print(f"⚠️ Screenshot failed: {error}")
|
||||
result = {
|
||||
"ok": True,
|
||||
"dashboard_id": str(did),
|
||||
"url": url,
|
||||
"screenshot": None,
|
||||
"screenshot_error": error,
|
||||
"title": title,
|
||||
"charts_count": len(charts_config)
|
||||
}
|
||||
print(f"\n__RESULT_JSON__\n{json.dumps(result, ensure_ascii=False)}\n__END_JSON__")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Deployment failed: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,247 @@
|
||||
import json, re, time, random, os, sys, requests
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# Local SDK client
|
||||
from client import DataEaseClient
|
||||
from engine import DataEaseChartEngine
|
||||
|
||||
class MultiDataEaseChartEngine(DataEaseChartEngine):
|
||||
def __init__(self, base_url, ak, sk):
|
||||
super().__init__(base_url, ak, sk)
|
||||
|
||||
def extract_chart_payload(self, chart_type: str, dataset_id: str, x_names: list, y_names: list, view_id: str, layout: dict, title: str = None):
|
||||
"""
|
||||
Reads the single chart template, applies parameters, and extracts the componentData & canvasViewInfo.
|
||||
"""
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
tpl_dir = os.path.join(base_dir, "..", "templates", f"chart_{chart_type}")
|
||||
|
||||
if not os.path.exists(tpl_dir):
|
||||
raise FileNotFoundError(f"Template directory {tpl_dir} not found")
|
||||
|
||||
with open(os.path.join(tpl_dir, "template.j2")) as f:
|
||||
template_str = f.read()
|
||||
|
||||
with open(os.path.join(tpl_dir, "params.json")) as f:
|
||||
raw_params = json.load(f)
|
||||
|
||||
# Build Context (Flatten params + dynamic IDs)
|
||||
ctx = {}
|
||||
def flatten_dict(d):
|
||||
items = {}
|
||||
for k, v in d.items():
|
||||
if isinstance(v, dict):
|
||||
items.update(flatten_dict(v))
|
||||
elif not k.startswith("_"):
|
||||
items[k] = v
|
||||
return items
|
||||
|
||||
ctx.update(flatten_dict(raw_params))
|
||||
ctx.update(self.get_dataset_ctx(dataset_id, x_names, y_names))
|
||||
|
||||
# Runtime Randomization
|
||||
# SCENE_ID is usually "0" for base
|
||||
ctx.update({"VIEW_ID": view_id, "SCENE_ID": "0"})
|
||||
|
||||
# Sub placeholder
|
||||
def rep(m):
|
||||
k = m.group(1).strip()
|
||||
if k in ctx:
|
||||
return str(ctx[k])
|
||||
return m.group(0)
|
||||
|
||||
rendered = re.sub(r"\{\{\s*(\w+)\s*\}\}", rep, template_str)
|
||||
payload = json.loads(rendered)
|
||||
|
||||
# Extract canvasViewInfo
|
||||
canvas_view_info = payload.get("canvasViewInfo", {})
|
||||
target_view_info = canvas_view_info.get(view_id)
|
||||
if not target_view_info:
|
||||
raise ValueError(f"View ID {view_id} not found in the rendered template!")
|
||||
|
||||
# Override Chart Title in view_info if provided
|
||||
if title:
|
||||
target_view_info["title"] = title
|
||||
|
||||
# --- Fix: Robust field name replacement ---
|
||||
def deep_update_field_names(obj, target_id, new_name):
|
||||
if isinstance(obj, dict):
|
||||
if str(obj.get("id")) == str(target_id):
|
||||
obj["name"] = new_name
|
||||
if "description" in obj: obj["description"] = new_name
|
||||
if "originName" in obj: obj["originName"] = new_name
|
||||
if "dbFieldName" in obj: obj["dbFieldName"] = None
|
||||
if "optionLabel" in obj:
|
||||
obj["optionLabel"] = new_name + (obj["optionLabel"].partition("(")[1] + obj["optionLabel"].partition("(")[2] if "(" in obj["optionLabel"] else "")
|
||||
if "optionShowName" in obj:
|
||||
obj["optionShowName"] = new_name + (obj["optionShowName"].partition("(")[1] + obj["optionShowName"].partition("(")[2] if "(" in obj["optionShowName"] else "")
|
||||
for v in obj.values():
|
||||
deep_update_field_names(v, target_id, new_name)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
deep_update_field_names(item, target_id, new_name)
|
||||
|
||||
dataset_ctx = self.get_dataset_ctx(dataset_id, x_names, y_names)
|
||||
for i, x_name in enumerate(x_names):
|
||||
f_id = dataset_ctx.get(f"XAXIS{'' if i==0 else i+1}_FIELD_ID")
|
||||
if f_id: deep_update_field_names(target_view_info, f_id, x_name)
|
||||
|
||||
for i, y_name in enumerate(y_names):
|
||||
f_id = dataset_ctx.get(f"YAXIS{'' if i==0 else i+1}_FIELD_ID")
|
||||
if f_id: deep_update_field_names(target_view_info, f_id, y_name)
|
||||
|
||||
def brute_force_replace(obj, search_list, replace_list):
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if isinstance(v, str):
|
||||
for s, r in zip(search_list, replace_list):
|
||||
if s in v: obj[k] = v.replace(s, r)
|
||||
else: brute_force_replace(v, search_list, replace_list)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj: brute_force_replace(item, search_list, replace_list)
|
||||
|
||||
if x_names and y_names:
|
||||
brute_force_replace(target_view_info, ["访问平台", "访问次数", "浏览量"], [x_names[0], y_names[0], y_names[0]])
|
||||
# --- End Fix ---
|
||||
|
||||
# Extract componentData
|
||||
component_data_str = payload.get("componentData", "[]")
|
||||
components = json.loads(component_data_str)
|
||||
if not components:
|
||||
raise ValueError(f"No component data found in the rendered template")
|
||||
|
||||
# We only take the first component as it was a single chart template
|
||||
target_component = components[0]
|
||||
|
||||
# Override component name/label if title provided
|
||||
if title:
|
||||
target_component["name"] = title
|
||||
target_component["label"] = title
|
||||
|
||||
# Override layout
|
||||
if "layout" in layout:
|
||||
layout = layout["layout"] # in case it's nested
|
||||
|
||||
if "style" not in target_component:
|
||||
target_component["style"] = {}
|
||||
|
||||
if "x" in layout: target_component["x"] = layout["x"]
|
||||
if "y" in layout: target_component["y"] = layout["y"]
|
||||
if "sizeX" in layout: target_component["sizeX"] = layout["sizeX"]
|
||||
if "sizeY" in layout: target_component["sizeY"] = layout["sizeY"]
|
||||
if "width" in layout: target_component["style"]["width"] = layout["width"]
|
||||
if "height" in layout: target_component["style"]["height"] = layout["height"]
|
||||
if "left" in layout: target_component["style"]["left"] = layout["left"]
|
||||
if "top" in layout: target_component["style"]["top"] = layout["top"]
|
||||
|
||||
return target_component, target_view_info
|
||||
|
||||
def deploy_multi(self, title: str, charts_config: List[Dict[str, Any]]):
|
||||
"""
|
||||
Deploy multiple charts into a single dashboard.
|
||||
charts_config supports optional 'layout'. If missing, it will auto-layout.
|
||||
"""
|
||||
content_id = self.rand_id()
|
||||
board_name = f"{title}_{int(time.time())}"
|
||||
|
||||
# Load base dashboard style from template
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
dashboard_tpl_path = os.path.join(base_dir, "..", "templates", "dashboard", "base.json")
|
||||
if os.path.exists(dashboard_tpl_path):
|
||||
with open(dashboard_tpl_path) as f:
|
||||
base_tpl = json.load(f)
|
||||
canvas_style_data = json.loads(base_tpl.get("canvasStyleData", "{}"))
|
||||
else:
|
||||
canvas_style_data = {
|
||||
"width": 1920, "height": 1080, "screenAdaptor": "widthFirst",
|
||||
"dashboard": {"gap": "yes", "gapSize": 5, "matrixBase": 4}
|
||||
}
|
||||
|
||||
final_component_data = []
|
||||
final_canvas_view_info = {}
|
||||
active_view_ids = []
|
||||
|
||||
# Auto-layout calculation constants
|
||||
GRID_COLS = 72
|
||||
CHART_WIDTH = 36
|
||||
CHART_HEIGHT = 14
|
||||
|
||||
# Process each chart sequentially
|
||||
for idx, c_conf in enumerate(charts_config):
|
||||
view_id = self.rand_id()
|
||||
active_view_ids.append(view_id)
|
||||
|
||||
# Calculate auto-layout if not provided
|
||||
layout = c_conf.get("layout", {})
|
||||
if not layout:
|
||||
row = idx // 2
|
||||
col = idx % 2
|
||||
layout = {
|
||||
"x": col * CHART_WIDTH + 1,
|
||||
"y": row * CHART_HEIGHT + 1,
|
||||
"sizeX": CHART_WIDTH,
|
||||
"sizeY": CHART_HEIGHT,
|
||||
"width": 519, "height": 65, # Standard relative sizes
|
||||
"left": col * 519, "top": row * 65
|
||||
}
|
||||
|
||||
comp_data, view_info = self.extract_chart_payload(
|
||||
chart_type=c_conf["type"],
|
||||
dataset_id=c_conf["dataset_name"],
|
||||
x_names=c_conf.get("x_axis", []),
|
||||
y_names=c_conf.get("y_axis", []),
|
||||
view_id=view_id,
|
||||
layout=layout,
|
||||
title=c_conf.get("title")
|
||||
)
|
||||
|
||||
comp_data["_dragId"] = idx
|
||||
final_component_data.append(comp_data)
|
||||
final_canvas_view_info[view_id] = view_info
|
||||
|
||||
dashboard_payload = {
|
||||
"id": None,
|
||||
"name": board_name,
|
||||
"type": "dashboard",
|
||||
"status": 0,
|
||||
"dataState": "ready",
|
||||
"selfWatermarkStatus": True,
|
||||
"checkVersion": "2.10.20",
|
||||
"pid": "0",
|
||||
"mobileLayout": False,
|
||||
"canvasStyleData": json.dumps(canvas_style_data, separators=(',', ':'), ensure_ascii=False),
|
||||
"componentData": json.dumps(final_component_data, separators=(',', ':'), ensure_ascii=False),
|
||||
"canvasViewInfo": final_canvas_view_info,
|
||||
"contentId": content_id
|
||||
}
|
||||
|
||||
headers = self.client._get_headers()
|
||||
print(f"Deploying multi-chart dashboard '{title}' with {len(charts_config)} charts...")
|
||||
|
||||
save_r = requests.post(
|
||||
f"{self.base_url}{self.api_prefix}/dataVisualization/saveCanvas",
|
||||
headers=headers, json=dashboard_payload, verify=False, timeout=30
|
||||
)
|
||||
|
||||
if save_r.status_code != 200:
|
||||
raise Exception(f"saveCanvas failed: {save_r.text}")
|
||||
|
||||
dashboard_id = str(save_r.json()["data"])
|
||||
|
||||
# Publish
|
||||
requests.post(
|
||||
f"{self.base_url}{self.api_prefix}/dataVisualization/updatePublishStatus",
|
||||
headers=headers, verify=False, timeout=30,
|
||||
json={
|
||||
"id": dashboard_id,
|
||||
"name": board_name,
|
||||
"activeViewIds": active_view_ids,
|
||||
"status": 1,
|
||||
"type": "dashboard",
|
||||
"mobileLayout": False
|
||||
}
|
||||
)
|
||||
|
||||
return dashboard_id, f"{self.base_url}/#/preview?dvId={dashboard_id}&dvType=dashboard&ignoreParams=true"
|
||||
|
||||
return dashboard_id, f"{self.base_url}/#/preview?dvId={dashboard_id}&dvType=dashboard&ignoreParams=true"
|
||||
Reference in New Issue
Block a user