# -*- coding: utf-8 -*- """ JeecgBoot 大屏/仪表盘设计器 Python 工具库 用于通过 API 自动创建和管理大屏、仪表盘页面及其组件。 使用方式: from bi_utils import * init_api('https://api3.boot.jeecg.com', 'your-token') page_id = create_page('销售大屏', style='bigScreen') add_number(page_id, '销售额', x=50, y=50, w=400, h=200, value=128560) add_chart(page_id, 'JBar', '月度销售', x=50, y=280, w=860, h=380, categories=['1月','2月','3月'], series=[{'name':'销售额','data':[820,932,901]}]) save_page(page_id) """ import json import urllib.request import urllib.parse import time import random import uuid # ============================================================ # 全局配置 # ============================================================ API_BASE = '' TOKEN = '' # 内存中缓存页面组件数据,save_page 时一次性提交 _page_components = {} # {page_id: [component_dict, ...]} _page_info = {} # {page_id: {name, style, theme, ...}} # ============================================================ # 大屏 vs 仪表盘 模式预设(颜色、样式完全不同) # ============================================================ # 大屏(bigScreen)- 深色背景,亮色文字 _BIGSCREEN = { 'bg': 'rgba(0,0,0,0)', 'border_color': '', 'title_color': '#ffffff', 'axis_color': '#ffffff', 'grid_color': 'rgba(255,255,255,0.1)', 'body_color': '#ffffff', 'suffix_color': '#ffffff', 'legend_color': '#ffffff', 'tooltip_color': '#ffffff', 'card': {'title': '', 'extra': '', 'rightHref': '', 'size': 'small'}, 'number_font_size': 32, # 表格 'table_header_bg': 'rgba(0,0,0,0.3)', 'table_header_color': '#ffffff', 'table_body_bg': 'rgba(0,0,0,0.1)', 'table_body_color': '#ffffff', 'table_body_font_size': 14, 'table_header_font_size': 14, # 滚动表格 'scroll_odd_color': '#0a2732', 'scroll_even_color': '#003b51', 'scroll_header_bg': '#0a73ff', 'scroll_header_color': '#ffffff', 'scroll_body_color': '#ffffff', 'scroll_border_color': 'rgba(255,255,255,0.1)', # 排行榜 'ranking_color': '#1370fb', 'ranking_text_color': '#fff', } # 仪表盘(default)- 亮色背景,深色文字,带卡片头 _DASHBOARD = { 'bg': '#FFFFFF', 'border_color': '#E8E8E8', 'title_color': '#464646', 'axis_color': '#909198', 'grid_color': '#F3F3F3', 'body_color': '#464646', 'suffix_color': '#909198', 'legend_color': '#464646', 'tooltip_color': '#464646', 'card': { 'title': '', # 由各函数填充 'extra': '', 'rightHref': '', 'size': 'default', 'headColor': '#FFFFFF', 'textStyle': {'color': '#464646', 'fontSize': 16, 'fontWeight': 'bold'}, }, 'number_font_size': 32, # 表格 'table_header_bg': '#FAFAFA', 'table_header_color': '#464646', 'table_body_bg': '#FFFFFF', 'table_body_color': '#666666', 'table_body_font_size': 13, 'table_header_font_size': 14, # 滚动表格 'scroll_odd_color': '#FFFFFF', 'scroll_even_color': '#FAFAFA', 'scroll_header_bg': '#F0F0F0', 'scroll_header_color': '#464646', 'scroll_body_color': '#666666', 'scroll_border_color': '#E8E8E8', # 排行榜 'ranking_color': '#1890FF', 'ranking_text_color': '#464646', } def _get_mode(page_id): """获取页面模式预设(大屏 or 仪表盘)""" info = _page_info.get(page_id, {}) style = info.get('style', 'bigScreen') if style == 'default': return _DASHBOARD return _BIGSCREEN def _make_card(mode, title): """根据模式创建 card 配置。 大屏模式:card.title 保持为空(标题由 ECharts option.title 显示,避免重复) 仪表盘模式:card.title 设置标题(卡片头显示) """ card = dict(mode['card']) if 'textStyle' in mode['card']: card['textStyle'] = dict(mode['card']['textStyle']) # 大屏不用 card 标题头,仪表盘用 if mode is _DASHBOARD: card['title'] = title else: card['title'] = '' return card def init_api(api_base, token): """初始化 API 地址和 Token""" global API_BASE, TOKEN API_BASE = api_base.rstrip('/') TOKEN = token print(f'[bi_utils] API: {API_BASE}') # ============================================================ # HTTP 工具 # ============================================================ def _request(method, path, data=None, params=None): """发送 HTTP 请求""" url = f'{API_BASE}{path}' if params: url += '?' + urllib.parse.urlencode(params) headers = { 'Content-Type': 'application/json;charset=UTF-8', 'X-Access-Token': TOKEN, } body = None if data is not None: body = json.dumps(data, ensure_ascii=False).encode('utf-8') req = urllib.request.Request(url, data=body, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as resp: result = json.loads(resp.read().decode('utf-8')) return result except urllib.error.HTTPError as e: error_body = e.read().decode('utf-8') if e.fp else '' print(f'[bi_utils] HTTP {e.code}: {error_body}') raise except Exception as e: print(f'[bi_utils] Request error: {e}') raise def _gen_key(): """生成唯一 key""" return f'{int(time.time() * 1000)}_{random.randint(100000, 999999)}' def _gen_uuid(): """生成 32 位无横线 UUID""" return uuid.uuid4().hex # ============================================================ # 页面管理 API # ============================================================ def create_page(name, style='bigScreen', theme=None, background_image=None, type_id='0', design_type=100, protection_code=''): """ 创建大屏或仪表盘页面。 Args: name: 页面名称 style: 'bigScreen'=大屏, 'default'=仪表盘 theme: 主题,默认大屏=dark,仪表盘=default background_image: 背景图路径,大屏默认 '/img/bg/bg4.png' type_id: 分类 ID,默认 '0' design_type: 设计类型 100=PC, 30=手机, 80=平板 protection_code: 保护密码 Returns: page_id: 页面 ID """ if theme is None: theme = 'dark' if style == 'bigScreen' else 'default' if background_image is None and style == 'bigScreen': background_image = '/img/bg/bg4.png' payload = { 'name': name, 'type': type_id, 'protectionCode': protection_code, 'theme': theme, 'style': style, 'backgroundImage': background_image or '', 'designType': design_type, } result = _request('POST', '/drag/page/add', data=payload) if not result.get('success'): raise Exception(f"创建页面失败: {result.get('message', json.dumps(result, ensure_ascii=False))}") page_data = result.get('result', {}) page_id = page_data.get('id') if not page_id: raise Exception(f"创建页面成功但未返回 ID: {json.dumps(result, ensure_ascii=False)}") # 缓存页面信息 _page_components[page_id] = [] _page_info[page_id] = { 'name': name, 'style': style, 'theme': theme, 'backgroundImage': background_image or '', 'designType': design_type, 'updateCount': page_data.get('updateCount', 1), 'path': page_data.get('path', ''), } print(f'[bi_utils] 页面创建成功: {name} (ID: {page_id})') return page_id def query_page(page_id): """查询页面详情(含组件配置)""" result = _request('GET', '/drag/page/queryById', params={'id': page_id}) if not result.get('success'): raise Exception(f"查询页面失败: {result.get('message')}") page = result.get('result', {}) # 更新缓存 if page_id not in _page_info: _page_info[page_id] = {} _page_info[page_id]['updateCount'] = page.get('updateCount', 1) _page_info[page_id]['name'] = page.get('name', '') # 解析 template template = page.get('template') if template and isinstance(template, str): try: page['template'] = json.loads(template) except: pass return page def list_pages(style=None, page_no=1, page_size=50): """列表查询页面""" params = {'pageNo': page_no, 'pageSize': page_size} if style: params['style'] = style result = _request('GET', '/drag/page/list', params=params) if not result.get('success'): raise Exception(f"查询列表失败: {result.get('message')}") return result.get('result', {}) def save_page(page_id): """ 保存页面设计(将所有缓存的组件一次性提交)。 通过 POST /drag/page/edit 提交,后端会: 1. 删除所有旧的 OnlDragPageComp 记录 2. 从 template 中提取 config 创建新的 comp 记录 3. 更新 template(移除 config,注入 pageCompId) """ components = _page_components.get(page_id, []) info = _page_info.get(page_id, {}) # 始终查询最新页面信息,确保 updateCount 正确 try: page = query_page(page_id) info = _page_info.get(page_id, {}) # 如果本地没有新增组件,使用已有的 if not components: existing_template = page.get('template', []) if isinstance(existing_template, list): components = existing_template except Exception as e: print(f'[bi_utils] 查询页面警告: {e},使用缓存信息') # 构建 template JSON template = json.dumps(components, ensure_ascii=False) payload = { 'id': page_id, 'name': info.get('name', ''), 'template': template, 'updateCount': info.get('updateCount', 1), 'style': info.get('style', 'bigScreen'), 'theme': info.get('theme', 'dark'), 'backgroundImage': info.get('backgroundImage', ''), 'designType': info.get('designType', 100), } result = _request('POST', '/drag/page/edit', data=payload) if not result.get('success'): raise Exception(f"保存页面失败: {result.get('message')}") # 更新 updateCount new_count = result.get('result', {}) if isinstance(new_count, dict): info['updateCount'] = new_count.get('updateCount', info.get('updateCount', 1) + 1) else: info['updateCount'] = info.get('updateCount', 1) + 1 print(f'[bi_utils] 页面保存成功: {info.get("name", page_id)} ({len(components)} 个组件)') return True def delete_page(page_id, physical=False): """ 删除页面。 Args: page_id: 页面 ID physical: True=物理删除(彻底),False=逻辑删除(回收站) """ if physical: result = _request('DELETE', '/drag/page/physicalDelete', params={'id': page_id}) else: result = _request('DELETE', '/drag/page/delete', params={'id': page_id}) if not result.get('success'): raise Exception(f"删除页面失败: {result.get('message')}") # 清理缓存 _page_components.pop(page_id, None) _page_info.pop(page_id, None) print(f'[bi_utils] 页面删除成功: {page_id} ({"物理删除" if physical else "逻辑删除"})') return True def recover_page(page_id): """恢复回收站中的页面""" result = _request('POST', '/drag/page/recoveryDelete', data={'id': page_id}) if not result.get('success'): raise Exception(f"恢复页面失败: {result.get('message')}") print(f'[bi_utils] 页面恢复成功: {page_id}') return True def copy_page(page_id): """复制页面""" result = _request('GET', '/drag/page/copyPage', params={'id': page_id}) if not result.get('success'): raise Exception(f"复制页面失败: {result.get('message')}") new_page = result.get('result', {}) new_id = new_page.get('id') print(f'[bi_utils] 页面复制成功: {page_id} → {new_id}') return new_id # ============================================================ # 组件添加函数 # ============================================================ def add_component(page_id, component, title, x, y, w, h, config=None): """ 添加通用组件到页面。 Args: page_id: 页面 ID component: 组件类型,如 'JBar', 'JNumber', 'JTable' title: 组件标题 x, y: 位置(大屏=像素,仪表盘=栅格) w, h: 尺寸(大屏=像素,仪表盘=栅格) config: 组件配置 dict(可选,会与默认配置合并) Returns: component dict(已加入缓存) """ if page_id not in _page_components: _page_components[page_id] = [] key = _gen_key() # 栅格单位转换为像素(仪表盘模式) info = _page_info.get(page_id, {}) style = info.get('style', 'bigScreen') if style == 'default': px_w = w * 75 px_h = h * 11 else: px_w = w px_h = h # 基础配置 default_config = { 'dataType': 1, 'timeOut': 0, 'size': {'width': px_w, 'height': px_h}, 'chart': { 'subclass': component, 'category': _get_category(component), }, 'option': {}, 'chartData': [], 'linkageConfig': [], 'turnConfig': {'url': '', 'type': '_blank'}, 'linkType': 'url', } # 合并用户配置 if config: _deep_merge(default_config, config) comp = { 'component': component, 'i': key, 'x': x, 'y': y, 'w': w, 'h': h, 'pcX': x, 'pcY': y, 'pcW': w, 'orderNum': len(_page_components[page_id]), 'config': json.dumps(default_config, ensure_ascii=False), } _page_components[page_id].append(comp) return comp def add_number(page_id, title, x, y, w, h, value=0, prefix='', suffix='', font_size=None, color=None, bg_color=None): """ 添加数字指标组件(JNumber)。 自动根据页面模式(大屏/仪表盘)应用不同默认样式。 Args: value: 显示的数值 prefix: 前缀(如 '¥') suffix: 后缀(如 '元', '%') font_size: 字体大小(默认根据模式自动设置) color: 字体颜色(默认根据模式自动设置) """ mode = _get_mode(page_id) if font_size is None: font_size = mode['number_font_size'] if color is None: color = mode['body_color'] config = { 'dataType': 1, 'chartData': json.dumps({'value': value}, ensure_ascii=False), 'background': mode['bg'], 'borderColor': mode['border_color'], 'option': { 'title': {'text': title, 'show': True, 'textStyle': {'color': mode['title_color'], 'fontSize': 14}}, 'body': { 'text': '', 'color': color, 'fontSize': font_size, 'fontWeight': 'bold', 'marginLeft': 0, 'marginTop': 0, }, 'card': _make_card(mode, title), 'prefix': prefix, 'prefixColor': mode['suffix_color'], 'prefixFontSize': 14, 'suffix': suffix, 'suffixColor': mode['suffix_color'], 'suffixFontSize': 14, 'isCompare': False, 'trendType': '1', }, 'analysis': { 'isCompare': False, 'compareType': '', 'trendType': '1', }, } return add_component(page_id, 'JNumber', title, x, y, w, h, config) def add_chart(page_id, chart_type, title, x, y, w, h, categories=None, series=None, pie_data=None): """ 添加图表组件。 支持的 chart_type: JBar, JLine, JSmoothLine, JHorizontalBar, JStackBar, JMixLineBar, DoubleLineBar, JPie, JRing, JRose, JFunnel, JRadar, JScatter, JGauge, JLiquid, JProgress, JWordCloud, JAreaMap, JFlyLineMap, 等等。 Args: chart_type: 组件类型 categories: X轴类目数据 ['一月','二月',...](轴类图表用) series: 系列数据 [{'name':'系列1','data':[1,2,3]}](轴类图表用) pie_data: 饼图数据 [{'name':'A','value':10}](饼/环/玫瑰图用) """ # 确定图表 ECharts 类型 echart_type_map = { 'JBar': 'bar', 'JHorizontalBar': 'bar', 'JBackgroundBar': 'bar', 'JMultipleBar': 'bar', 'JNegativeBar': 'bar', 'JStackBar': 'bar', 'JLine': 'line', 'JSmoothLine': 'line', 'JStepLine': 'line', 'JMultipleLine': 'line', 'JMixLineBar': 'bar', # 混合类型 'JPie': 'pie', 'JRing': 'pie', 'JRose': 'pie', 'JFunnel': 'funnel', 'JRadar': 'radar', 'JScatter': 'scatter', 'JBubble': 'scatter', 'JGauge': 'gauge', } echart_type = echart_type_map.get(chart_type, 'bar') # 构建 chartData chart_data = [] if pie_data: chart_data = pie_data elif categories and series: if len(series) == 1: # 单系列:简单 name/value for i, cat in enumerate(categories): chart_data.append({ 'name': cat, 'value': series[0]['data'][i] if i < len(series[0]['data']) else 0, }) else: # 多系列:需要 type 字段区分 for s in series: for i, cat in enumerate(categories): chart_data.append({ 'name': cat, 'value': s['data'][i] if i < len(s['data']) else 0, 'type': s.get('name', ''), }) mode = _get_mode(page_id) # 构建 ECharts option(根据模式应用不同颜色) option = { 'title': {'text': title, 'show': True, 'textStyle': {'color': mode['title_color'], 'fontSize': 16}, 'subtextStyle': {'color': mode['axis_color']}}, 'tooltip': {'show': True, 'textStyle': {'color': mode['tooltip_color']}}, 'legend': {'show': len(series or []) > 1 or bool(pie_data), 'textStyle': {'color': mode['legend_color'], 'fontSize': 12}}, 'grid': {'left': 60, 'right': 30, 'top': 70, 'bottom': 40, 'show': False}, 'card': _make_card(mode, title), } if pie_data: # 饼图系列 radius = '55%' if chart_type == 'JRing': radius = ['40%', '55%'] elif chart_type == 'JRose': radius = ['20%', '55%'] option['tooltip']['trigger'] = 'item' if chart_type in ('JPie', 'JRing', 'JRose'): option['legend']['orient'] = 'vertical' option['series'] = [{ 'name': title, 'type': 'pie', 'radius': radius, 'data': pie_data, 'emphasis': {'itemStyle': {'shadowBlur': 10, 'shadowOffsetX': 0, 'shadowColor': 'rgba(0,0,0,0.5)'}}, }] elif categories and series: # 轴类图表 option['xAxis'] = { 'type': 'category', 'show': True, 'data': categories, 'axisLabel': {'color': mode['axis_color']}, 'axisLine': {'lineStyle': {'color': mode['grid_color']}}, } option['yAxis'] = { 'type': 'value', 'show': True, 'axisLabel': {'color': mode['axis_color']}, 'splitLine': {'lineStyle': {'color': mode['grid_color']}}, } if chart_type == 'JHorizontalBar': option['xAxis'], option['yAxis'] = option['yAxis'], option['xAxis'] option['yAxis']['data'] = categories option['yAxis']['type'] = 'category' option['xAxis'] = { 'type': 'value', 'show': True, 'axisLabel': {'color': mode['axis_color']}, 'axisLine': {'lineStyle': {'color': mode['grid_color']}}, 'splitLine': {'lineStyle': {'color': mode['grid_color']}}, } option['series'] = [] for s in series: series_item = { 'name': s.get('name', ''), 'type': echart_type, 'data': s.get('data', []), } if chart_type == 'JSmoothLine': series_item['smooth'] = True if chart_type == 'JStepLine': series_item['step'] = 'middle' if chart_type == 'JStackBar': series_item['stack'] = 'total' option['series'].append(series_item) # 仪表盘模式:card.title 留空,仅用 option.title 显示标题(避免重复) option['card'] = _make_card(mode, '') config = { 'dataType': 1, 'chartData': json.dumps(chart_data, ensure_ascii=False), 'background': mode['bg'], 'borderColor': mode['border_color'], 'option': option, } return add_component(page_id, chart_type, title, x, y, w, h, config) def add_table(page_id, title, x, y, w, h, columns=None, data=None): """ 添加数据表格组件(JTable)。 Args: columns: 列名列表 ['姓名', '年龄', '地址'] data: 数据行列表 [{'姓名':'张三','年龄':'28','地址':'北京'}] """ columns = columns or [] data = data or [] chart_data = [] for col in columns: field_name = col.lower().replace(' ', '_') chart_data.append({ 'fieldTxt': col, 'fieldName': field_name, 'type': 'field', 'isShow': 'Y', 'isTotal': 'N', }) mode = _get_mode(page_id) config = { 'dataType': 1, 'chartData': json.dumps(chart_data, ensure_ascii=False), 'background': mode['bg'], 'borderColor': mode['border_color'], 'option': { 'title': {'text': title, 'show': True, 'textStyle': {'color': mode['title_color'], 'fontSize': 16}}, 'bordered': True, 'size': 'small', 'headerBgColor': mode['table_header_bg'], 'headerColor': mode['table_header_color'], 'headerFontSize': mode['table_header_font_size'], 'bodyBgColor': mode['table_body_bg'], 'bodyColor': mode['table_body_color'], 'bodyFontSize': mode['table_body_font_size'], 'card': _make_card(mode, title), 'data': data, }, } return add_component(page_id, 'JTable', title, x, y, w, h, config) def add_scroll_table(page_id, title, x, y, w, h, columns=None, data=None): """ 添加自动滚动表格组件(JScrollTable)。 Args: columns: 列名列表 data: 数据行列表(二维数组格式) """ columns = columns or [] data = data or [] # JScrollTable expects chartData as array of objects # columns maps to option.fieldMapping field_mapping = [] for col in columns: field_name = col.lower().replace(' ', '_') field_mapping.append({'name': col, 'key': field_name, 'width': 0}) # Convert data rows: if data is list of lists, convert to list of dicts chart_data = [] if data and isinstance(data[0], (list, tuple)): for row in data: item = {} for j, col in enumerate(columns): field_name = col.lower().replace(' ', '_') item[field_name] = row[j] if j < len(row) else '' chart_data.append(item) elif data and isinstance(data[0], dict): chart_data = data else: chart_data = data or [] mode = _get_mode(page_id) config = { 'dataType': 1, 'chartData': json.dumps(chart_data, ensure_ascii=False), 'background': mode['bg'], 'borderColor': mode['border_color'], 'option': { 'title': {'text': title, 'show': True, 'textStyle': {'color': mode['title_color'], 'fontSize': 16}}, 'ranking': False, 'textPosition': 'center', 'lineHeight': 50, 'fontSize': 20, 'bodyFontSize': 18, 'scrollTime': 50, 'scroll': True, 'showBorder': True, 'borderWidth': 1, 'borderColor': mode['scroll_border_color'], 'borderStyle': 'solid', 'showHead': True, 'bodyFontColor': mode['scroll_body_color'], 'oddColor': mode['scroll_odd_color'], 'evenColor': mode['scroll_even_color'], 'headerBgColor': mode['scroll_header_bg'], 'headerFontColor': mode['scroll_header_color'], 'fieldMapping': field_mapping, 'card': _make_card(mode, title), }, } return add_component(page_id, 'JScrollTable', title, x, y, w, h, config) def add_ranking(page_id, title, x, y, w, h, data=None): """ 添加排行榜组件(JScrollRankingBoard)。 Args: data: 排行数据 [{'name':'项目A','value':100}, ...] """ data = data or [] mode = _get_mode(page_id) config = { 'dataType': 1, 'chartData': json.dumps(data, ensure_ascii=False), 'background': mode['bg'], 'borderColor': mode['border_color'], 'option': { 'title': {'text': title, 'show': True, 'textStyle': {'color': mode['title_color'], 'fontSize': 16}}, 'waitTime': 2000, 'rowNum': 5, 'carousel': 'single', 'sort': True, 'fontSize': 13, 'color': mode['ranking_color'], 'textColor': mode['ranking_text_color'], 'card': _make_card(mode, title), }, } return add_component(page_id, 'JScrollRankingBoard', title, x, y, w, h, config) def add_text(page_id, title, x, y, w, h, content='', font_size=16, color=None, font_weight='normal', text_align='left', letter_spacing=0): """ 添加文本组件(JText)。 使用与真实模板一致的 option.body 结构 + chartData: {"value": "..."} 格式。 Args: content: 文本内容 font_size: 字体大小 color: 字体颜色(默认根据模式自动设置) font_weight: 字体粗细 'normal'/'bold' text_align: 对齐方式 'left'/'center'/'right' letter_spacing: 字间距(大屏标题建议 5-8) """ mode = _get_mode(page_id) if color is None: color = mode['title_color'] text_value = content or title config = { 'dataType': 1, 'chartData': {'value': text_value}, 'option': { 'body': { 'color': color, 'fontSize': font_size, 'fontWeight': font_weight, 'letterSpacing': letter_spacing, 'text': '', 'marginTop': 0, 'marginLeft': 0, }, 'textAlign': text_align, 'card': {'title': '', 'extra': '', 'rightHref': '', 'size': 'default'}, 'openUrl': '', 'isLink': False, 'openType': '_blank', }, } return add_component(page_id, 'JText', title, x, y, w, h, config) def add_image(page_id, title, x, y, w, h, src=''): """ 添加图片组件(JImg)。 Args: src: 图片 URL """ config = { 'dataType': 1, 'chartData': src, 'option': { 'objectFit': 'cover', }, } return add_component(page_id, 'JImg', title, x, y, w, h, config) def add_gauge(page_id, title, x, y, w, h, value=0, max_val=100, unit='%', color='#00BAFF'): """ 添加仪表盘组件(JGauge)。 Args: value: 当前值 max_val: 最大值 unit: 单位 color: 指针颜色 """ mode = _get_mode(page_id) tail_color = '#333' if mode is _BIGSCREEN else '#E8E8E8' config = { 'dataType': 1, 'chartData': json.dumps([{'name': title, 'value': value}], ensure_ascii=False), 'background': mode['bg'], 'borderColor': mode['border_color'], 'option': { 'title': {'text': title, 'show': True, 'textStyle': {'color': mode['title_color'], 'fontSize': 16}}, 'card': _make_card(mode, title), 'series': [{ 'type': 'gauge', 'max': max_val, 'detail': {'formatter': f'{{value}}{unit}'}, 'data': [{'value': value, 'name': title}], 'axisLine': { 'lineStyle': { 'color': [[value / max_val, color], [1, tail_color]], } }, }], }, } return add_component(page_id, 'JGauge', title, x, y, w, h, config) def add_liquid(page_id, title, x, y, w, h, value=50, color='#00BAFF'): """ 添加水球图组件(JLiquid)。 Args: value: 0~100 的百分比值(如 97.3 表示 97.3%) color: 颜色 """ mode = _get_mode(page_id) config = { 'dataType': 1, 'chartData': json.dumps([{'value': value}], ensure_ascii=False), 'background': mode['bg'], 'borderColor': mode['border_color'], 'option': { 'title': {'text': title, 'show': True, 'textStyle': {'color': mode['title_color'], 'fontSize': 16}}, 'liquidType': 'circle', 'color': color, 'borderWidth': 2, 'distance': 1, 'borderColor': color, 'strokeOpacity': 0, 'count': 4, 'length': 128, 'textColor': mode['title_color'], 'textFontSize': 30, 'card': _make_card(mode, title), }, } return add_component(page_id, 'JLiquid', title, x, y, w, h, config) def add_countdown(page_id, title, x, y, w, h, value=0, font_size=48, color='#00BAFF'): """ 添加数字翻牌器组件(JCountTo)。 Args: value: 目标数值 font_size: 字体大小 color: 字体颜色 """ mode = _get_mode(page_id) if color == '#00BAFF' and mode is _DASHBOARD: color = mode['body_color'] config = { 'dataType': 1, 'chartData': str(value), 'background': mode['bg'], 'borderColor': mode['border_color'], 'option': { 'title': {'text': title, 'show': True, 'textStyle': {'color': mode['title_color'], 'fontSize': 16}}, 'endVal': value, 'fontSize': font_size, 'color': color, 'duration': 2000, 'card': _make_card(mode, title), }, } return add_component(page_id, 'JCountTo', title, x, y, w, h, config) def add_border(page_id, x, y, w, h, border_type=1, color='#00BAFF'): """ 添加装饰边框组件(JDragBorder)。 Args: border_type: 边框样式 1~13 color: 边框颜色 """ config = { 'dataType': 1, 'option': { 'borderType': border_type, 'color': [color], }, } return add_component(page_id, 'JDragBorder', f'边框{border_type}', x, y, w, h, config) def add_decoration(page_id, x, y, w, h, deco_type=1, color='#00BAFF'): """ 添加装饰条组件(JDragDecoration)。 Args: deco_type: 装饰样式 1~12 color: 颜色 """ config = { 'dataType': 1, 'option': { 'decorationType': deco_type, 'color': [color], }, } return add_component(page_id, 'JDragDecoration', f'装饰{deco_type}', x, y, w, h, config) # ============================================================ # 编辑已有页面 # ============================================================ def update_page(page_id, new_components=None): """ 更新已有页面的组件。 Args: page_id: 页面 ID new_components: 新的组件列表(完全替换) """ # 查询当前页面信息 page = query_page(page_id) if new_components is not None: _page_components[page_id] = new_components elif page_id not in _page_components: # 从已有页面加载组件 template = page.get('template', []) if isinstance(template, str): try: template = json.loads(template) except: template = [] _page_components[page_id] = template return save_page(page_id) def add_to_existing(page_id, component_func, *args, **kwargs): """ 向已有页面追加组件。先加载已有组件,再添加新组件,最后保存。 用法: add_to_existing(page_id, add_number, '新指标', x=500, y=0, w=400, h=200, value=999) """ if page_id not in _page_components or not _page_components[page_id]: # 先加载已有组件 page = query_page(page_id) template = page.get('template', []) if isinstance(template, str): try: template = json.loads(template) except: template = [] _page_components[page_id] = template # 调用组件添加函数 return component_func(page_id, *args, **kwargs) # ============================================================ # 菜单 SQL 生成 # ============================================================ ROLE_ID = 'f6817f48af4fb3af11b9e8bf182f618b' def gen_menu_sql(parent_name, children, icon='ant-design:appstore-outlined', role_id=None): """ 生成菜单 SQL + 角色授权 SQL。 Args: parent_name: 父菜单名称 children: [(名称, page_id, sort_no), ...] icon: 父菜单图标 role_id: 角色 ID,默认使用 ROLE_ID Returns: SQL 字符串 """ rid = role_id or ROLE_ID parent_id = _gen_uuid() lines = [] # 父菜单 lines.append(f"-- 父菜单: {parent_name}") lines.append( f"INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, " f"redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, " f"is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, " f"create_by, create_time, update_by, update_time, internal_or_external) " f"VALUES ('{parent_id}', NULL, '{parent_name}', '/{parent_id}', " f"'layouts/RouteView', NULL, NULL, 0, NULL, '1', 1.00, 0, '{icon}', " f"1, 0, 0, 0, 0, NULL, '1', 0, 0, 'admin', now(), NULL, NULL, 0);" ) rp_id = _gen_uuid() lines.append( f"INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, " f"operate_date, operate_ip) VALUES ('{rp_id}', '{rid}', '{parent_id}', NULL, " f"now(), '127.0.0.1');" ) lines.append('') # 子菜单 for name, page_id, sort_no in children: menu_id = _gen_uuid() lines.append(f"-- 子菜单: {name}") lines.append( f"INSERT INTO sys_permission(id, parent_id, name, url, component, component_name, " f"redirect, menu_type, perms, perms_type, sort_no, always_show, icon, is_route, " f"is_leaf, keep_alive, hidden, hide_tab, description, status, del_flag, rule_flag, " f"create_by, create_time, update_by, update_time, internal_or_external) " f"VALUES ('{menu_id}', '{parent_id}', '{name}', " f"'/drag/page/view/{page_id}', " f"'super/drag/page/dashboardPreview', 'dashboardPreview', " f"NULL, 0, NULL, '1', {sort_no}.00, 0, NULL, 0, 1, 0, 0, 0, NULL, '1', " f"0, 0, 'admin', now(), NULL, NULL, 0);" ) rp_id2 = _gen_uuid() lines.append( f"INSERT INTO sys_role_permission (id, role_id, permission_id, data_rule_ids, " f"operate_date, operate_ip) VALUES ('{rp_id2}', '{rid}', '{menu_id}', NULL, " f"now(), '127.0.0.1');" ) lines.append('') return '\n'.join(lines) # ============================================================ # 辅助函数 # ============================================================ def _get_category(component): """根据组件类型获取分类""" category_map = { 'JBar': 'Bar', 'JHorizontalBar': 'Bar', 'JBackgroundBar': 'Bar', 'JMultipleBar': 'Bar', 'JNegativeBar': 'Bar', 'JStackBar': 'Bar', 'JDynamicBar': 'Bar', 'JCapsuleChart': 'Bar', 'JLine': 'Line', 'JSmoothLine': 'Line', 'JStepLine': 'Line', 'JMultipleLine': 'Line', 'JMixLineBar': 'MixLineBar', 'DoubleLineBar': 'DoubleLineBar', 'JPie': 'Pie', 'JRing': 'Ring', 'JRose': 'Rose', 'JGauge': 'Gauge', 'JColorGauge': 'Gauge', 'JSemiGauge': 'Gauge', 'JProgress': 'Progress', 'JCustomProgress': 'Progress', 'JLiquid': 'Liquid', 'JRadialBar': 'RadialBar', 'JFunnel': 'Funnel', 'JPyramidFunnel': 'Funnel', 'JRadar': 'Radar', 'JCircleRadar': 'Radar', 'JScatter': 'Scatter', 'JBubble': 'Bubble', 'JWordCloud': 'WordCloud', 'JAreaMap': 'Map', 'JBubbleMap': 'Map', 'JFlyLineMap': 'Map', 'JHeatMap': 'Map', 'JBarMap': 'Map', 'JBar3d': '3D', 'JBarGroup3d': '3D', 'JNumber': 'Number', 'JCountTo': 'CountTo', 'JTable': 'Table', 'JScrollTable': 'ScrollTable', 'JPivotTable': 'PivotTable', 'JScrollRankingBoard': 'Ranking', 'JText': 'Text', 'JImg': 'Image', 'JCarousel': 'Carousel', 'JVideoPlay': 'Video', 'JCustomButton': 'Button', 'JTabs': 'Tabs', 'JDragBorder': 'Border', 'JDragDecoration': 'Decoration', 'JIframe': 'Iframe', 'JCurrentTime': 'Time', } return category_map.get(component, 'Common') def _deep_merge(base, override): """深度合并字典,override 覆盖 base""" for k, v in override.items(): if k in base and isinstance(base[k], dict) and isinstance(v, dict): _deep_merge(base[k], v) else: base[k] = v return base