From 5e719c62f02c5984ef3d459ed45e2dbb8f83aaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= Date: Fri, 29 May 2026 09:08:29 +0800 Subject: [PATCH] =?UTF-8?q?ai=E6=A8=A1=E5=9D=97=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yuthon_ai_agent/controllers/report.py | 60 +- yuthon_ai_agent/models/ai_agent.py | 42 +- yuthon_ai_agent/models/ai_report.py | 67 +- yuthon_ai_agent/models/ai_report_template.py | 25 +- .../static/src/html/report_template.html | 953 +++++++++++++----- yuthon_ai_agent/views/ai_report_views.xml | 6 + 6 files changed, 890 insertions(+), 263 deletions(-) diff --git a/yuthon_ai_agent/controllers/report.py b/yuthon_ai_agent/controllers/report.py index 07dc142f3..01c8a712b 100644 --- a/yuthon_ai_agent/controllers/report.py +++ b/yuthon_ai_agent/controllers/report.py @@ -32,7 +32,7 @@ class AiReportController(http.Controller): @http.route('/ai/report/', type='http', auth='none', methods=['GET'], csrf=False) def report_page(self, token, **kw): - """渲染 H5 报表页面""" + """渲染 H5 报表页面(增强版:动态注入 REPORT_CONFIG)""" report, error = self._get_report_by_token(token) if error: return Response( @@ -40,16 +40,66 @@ class AiReportController(http.Controller): content_type='text/html; charset=utf-8' ) - # 读取 H5 模板并注入报表信息 + # 1. 获取基础数据 + data = report.fetch_data() + + # 2. 构建 REPORT_CONFIG + config = {} + template = report.template_id + + # 2a. 标题 + config['title'] = report.name + + # 2b. 列配置:优先使用模板的 report_config.columns + if template and template.report_config: + try: + tmpl_config = json.loads(template.report_config) + config['columns'] = tmpl_config.get('columns', []) + config['cards'] = tmpl_config.get('cards', []) + config['groupBy'] = tmpl_config.get('groupBy', []) + config['defaultVisible'] = tmpl_config.get('defaultVisible', 10) + config['colorRules'] = tmpl_config.get('colorRules', []) + config['filters'] = tmpl_config.get('filters', []) + except json.JSONDecodeError: + pass + + # 2c. 如果没有模板或模板没有列配置,从 report 自身字段生成 + if not config.get('columns'): + fields = json.loads(report.field_list or '[]') + labels = {} + if report.field_labels: + labels = json.loads(report.field_labels) + config['columns'] = [ + {'k': f, 'l': labels.get(f, f), 'w': 120} + for f in fields + ] + + # 2d. 处理数据加载失败 + if not data.get('success'): + config['data'] = [] + config['description'] = data.get('error', '数据加载失败') + else: + # 注入数据 + config['data'] = data.get('records', []) + config['description'] = "共 {} 条记录".format(data.get('count', 0)) + + # 2e. 子标题(数据概要) + fields = data.get('fields', []) + records_count = data.get('count', 0) + config['subtitle'] = "{} 个字段 · {} 条记录".format(len(fields), records_count) + + # 3. 读取 H5 模板 try: with open(_H5_TEMPLATE_PATH, 'r', encoding='utf-8') as f: html_template = f.read() except FileNotFoundError: return Response('模板文件未找到', content_type='text/plain') - # 替换模板中的占位变量 - html_content = html_template.replace('{{REPORT_NAME}}', report.name or '') - html_content = html_content.replace('{{DATA_URL}}', f'/ai/report/{token}/data') + # 4. 注入占位符 + config_json_str = json.dumps(config, ensure_ascii=False, default=str) + html_content = html_template.replace('{{REPORT_CONFIG_JSON}}', config_json_str) + html_content = html_content.replace('{{DATA_URL}}', '/ai/report/{}/data'.format(token)) + html_content = html_content.replace('{{REPORT_TITLE}}', report.name or '动态报表') return Response(html_content, content_type='text/html; charset=utf-8') diff --git a/yuthon_ai_agent/models/ai_agent.py b/yuthon_ai_agent/models/ai_agent.py index 7ca7690ab..8301a4b5c 100644 --- a/yuthon_ai_agent/models/ai_agent.py +++ b/yuthon_ai_agent/models/ai_agent.py @@ -253,6 +253,8 @@ class AiConversation(models.Model): '当查询结果数据较多(超过5条)或用户要求查看列表/报表时,' '生成一个动态报表H5页面链接。用户点击链接可在手机上查看整齐的表格数据。' '注意:必须先调用 odoo_fields_get 确认字段名称,再调用此工具。' + '【模板使用】如果已有匹配的报表模板,传入 template_id 来使用模板的' + '列配置、视觉风格和参数定义。参数通过 params 传入。' ), 'parameters': { 'type': 'object', @@ -263,6 +265,15 @@ class AiConversation(models.Model): 'field_labels': {'type': 'object', 'description': '字段中文标签,如 {"name":"订单号","amount_total":"金额"}'}, 'name': {'type': 'string', 'description': '报表名称,如"本月销售订单"'}, 'order': {'type': 'string', 'description': '排序,如 "create_date desc"'}, + 'template_id': { + 'type': 'integer', + 'description': '报表模板ID,使用模板的列配置和参数定义。可从"可用报表模板"列表中获取', + }, + 'params': { + 'type': 'object', + 'description': '模板参数值,key=参数名, value=参数值。根据模板的param_definitions传入,如{"date_from":"2025-01-01","customer":"张三"}', + 'additionalProperties': True, + }, }, 'required': ['model', 'fields', 'name'], }, @@ -425,7 +436,7 @@ class AiConversation(models.Model): return None def _execute_generate_report(self, args): - """执行 generate_report 工具:创建动态报表并返回 URL""" + """执行 generate_report 工具(增强版:支持 template_id + params)""" import json as _json model_name = args.get('model') @@ -440,9 +451,11 @@ class AiConversation(models.Model): domain = args.get('domain') or [] field_labels = args.get('field_labels') or {} order = args.get('order') or 'id desc' + template_id = args.get('template_id') + params = args.get('params') or {} try: - report = self.env['ai.report'].sudo().create({ + vals = { 'name': report_name, 'model_name': model_name, 'domain': _json.dumps(domain, ensure_ascii=False), @@ -450,13 +463,34 @@ class AiConversation(models.Model): 'field_labels': _json.dumps(field_labels, ensure_ascii=False) if field_labels else '', 'order': order, 'create_uid': self.env.uid, - }) + } + + # 如果指定了模板 + if template_id: + template = self.env['ai.report.template'].sudo().browse(template_id) + if template.exists(): + vals['template_id'] = template_id + # 如果 AI 没传 fields,用模板的字段列表 + if not args.get('fields') and template.field_list: + vals['field_list'] = template.field_list + # 如果 AI 没传 field_labels,用模板的 + if not args.get('field_labels') and template.field_labels: + vals['field_labels'] = template.field_labels + + # 如果有 params,序列化保存 + if params: + vals['params'] = _json.dumps(params, ensure_ascii=False) + + report = self.env['ai.report'].sudo().create(vals) url = report.get_report_url() - return { + + result = { 'success': True, 'url': url, 'message': f'已生成报表链接,用户可点击查看: {url}', } + + return result except Exception as e: _logger.exception('generate_report failed: %s', e) return {'error': f'生成报表失败: {e}'} diff --git a/yuthon_ai_agent/models/ai_report.py b/yuthon_ai_agent/models/ai_report.py index 2b42212c4..23aea2ef2 100644 --- a/yuthon_ai_agent/models/ai_report.py +++ b/yuthon_ai_agent/models/ai_report.py @@ -29,6 +29,8 @@ class AiReport(models.Model): active = fields.Boolean(default=True) expire_date = fields.Datetime('过期时间') template_id = fields.Many2one('ai.report.template', string='使用模板') + params = fields.Text('参数值', default='{}', + help='JSON对象,模板参数的实际值。由AI调用generate_report时传入,如{"date_from":"2025-01-01","customer":"张三"}') @api.model_create_multi def create(self, vals_list): @@ -52,45 +54,86 @@ class AiReport(models.Model): return f"{base_url}/ai/report/{self.access_token}" def fetch_data(self): - """执行查询并返回数据(使用 SUPERUSER 权限)""" + """执行查询并返回数据(增强版:支持模板参数合并、字段回退)""" self.ensure_one() try: model_name = self.model_name if model_name not in self.env: return {'error': f'模型 {model_name} 不存在'} - domain = json.loads(self.domain or '[]') - field_list = json.loads(self.field_list or '[]') - order = self.order or 'id desc' - limit = self.limit or 200 - Model = self.env[model_name].sudo() - records = Model.search_read(domain, field_list, limit=limit, order=order) - # 获取字段标签 + # ---- 1. 构建最终 domain ---- + final_domain = json.loads(self.domain or '[]') + + template = self.template_id + + # 1a. 如果有模板,合并模板的 default_domain + if template and template.default_domain: + try: + tmpl_domain = json.loads(template.default_domain) + if tmpl_domain: + final_domain = ['&'] + final_domain + tmpl_domain + except json.JSONDecodeError: + pass + + # 1b. 如果有模板参数定义 + report 有 params 值 → 生成参数 domain + if template and template.param_definitions and self.params: + try: + param_defs = json.loads(template.param_definitions) + params = json.loads(self.params) if isinstance(self.params, str) else self.params + param_conditions = [] + for pdef in param_defs: + param_name = pdef.get('name') + param_value = params.get(param_name) + if param_value is None or param_value == '': + continue + domain_field = pdef.get('domain_field', param_name) + op = pdef.get('op', '=') + param_conditions.append([domain_field, op, param_value]) + + if param_conditions: + for cond in param_conditions: + final_domain = ['&'] + final_domain + [cond] + except (json.JSONDecodeError, ValueError): + pass + + # ---- 2. 构建 field_list ---- + field_list = json.loads(self.field_list or '[]') + if not field_list and template and template.field_list: + field_list = json.loads(template.field_list) + + if not field_list: + return {'error': '未指定查询字段'} + + # ---- 3. 执行查询 ---- + order = self.order or (template.default_order if template else 'id desc') + limit = self.limit or 200 + records = Model.search_read(final_domain, field_list, limit=limit, order=order) + + # ---- 4. 字段标签处理 ---- labels = {} if self.field_labels: labels = json.loads(self.field_labels) + elif template and template.field_labels: + labels = json.loads(template.field_labels) else: - # 从模型中获取字段 string fields_info = Model.fields_get(field_list, attributes=['string', 'type']) for fname in field_list: if fname in fields_info: labels[fname] = fields_info[fname].get('string', fname) - # 处理 Many2one 字段(转为 display_name) + # ---- 5. Many2one/datetime 处理 ---- fields_info = Model.fields_get(field_list, attributes=['type']) for record in records: for fname in field_list: val = record.get(fname) ftype = fields_info.get(fname, {}).get('type', '') if ftype == 'many2one' and isinstance(val, (list, tuple)): - # Many2one 返回 [id, name],取 name record[fname] = val[1] if len(val) > 1 else str(val[0]) elif val is False: record[fname] = '' elif ftype in ('date', 'datetime') and val: - # 保持字符串格式 record[fname] = str(val) if not isinstance(val, str) else val return { diff --git a/yuthon_ai_agent/models/ai_report_template.py b/yuthon_ai_agent/models/ai_report_template.py index 0649d47d6..e0b92cfb7 100644 --- a/yuthon_ai_agent/models/ai_report_template.py +++ b/yuthon_ai_agent/models/ai_report_template.py @@ -25,19 +25,36 @@ class AiReportTemplate(models.Model): description = fields.Text('使用说明', help='告诉 AI 何时使用此模板,如"当用户查询销售订单列表时使用此模板"') sequence = fields.Integer('排序', default=10) + report_config = fields.Text('报表视觉配置', default='{}', + help='JSON格式的报表视觉配置,包含columns(列定义)、cards(汇总卡片)、groupBy(分组)、colorRules(颜色规则)、filters(筛选器)、defaultVisible(默认显示列数)') + param_definitions = fields.Text('模板参数定义', default='[]', + help='JSON数组,定义模板可接受的参数。每项包含name(参数名)、label(显示名)、type(参数类型:date/char/select/float)、domain_field(映射模型字段)、op(操作符)、description(说明)、options(select选项)') active = fields.Boolean(default=True) @api.model def get_templates_for_ai(self): - """获取所有启用模板的摘要,供 AI system_msg 注入""" + """获取所有启用模板的摘要,供 AI system_msg 注入(增强版:包含参数信息)""" templates = self.sudo().search([('active', '=', True)]) if not templates: return '' lines = [] for t in templates: fields_str = t.field_list or '[]' - lines.append( - f'- 模板"{t.name}":模型={t.model_name},字段={fields_str},' - f'说明={t.description or "无"}' + base_info = ( + f'- 模板"{t.name}":ID={t.id},模型={t.model_name},' + f'字段={fields_str},说明={t.description or "无"}' ) + # 注入参数定义信息 + if t.param_definitions: + try: + param_defs = json.loads(t.param_definitions) + if param_defs: + param_info = ',参数: ' + ', '.join([ + f'{p.get("name","")}({p.get("label","")}:{p.get("type","")})' + for p in param_defs + ]) + base_info += param_info + except json.JSONDecodeError: + pass + lines.append(base_info) return '\n'.join(lines) diff --git a/yuthon_ai_agent/static/src/html/report_template.html b/yuthon_ai_agent/static/src/html/report_template.html index b51f9aa10..f7f73ecc3 100644 --- a/yuthon_ai_agent/static/src/html/report_template.html +++ b/yuthon_ai_agent/static/src/html/report_template.html @@ -1,270 +1,747 @@ - - - {{REPORT_NAME}} - +/* ===== Freeze ===== */ +.fz-pin{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:4px;margin-left:4px;cursor:pointer;font-size:0.6rem;opacity:0;transition:.2s;vertical-align:middle;flex-shrink:0} +thead th:hover .fz-pin{opacity:0.4} +.fz-pin:hover{opacity:1!important;background:rgba(127,119,221,0.15)} +.fz-pin.on{opacity:0.7!important;background:rgba(127,119,221,0.12);color:var(--hl)} +thead th.sticky{position:sticky;z-index:11;background:var(--th-bg)} +thead th.sticky-l{left:0;border-right:1px solid var(--th-border)} +tbody td.sticky{position:sticky;z-index:2;background:var(--bg)} +tbody td.sticky-l{left:0;border-right:1px solid var(--bd);box-shadow:1px 0 4px var(--sh1)} +tbody tr:hover td.sticky{background:var(--bg2)} +.ts{overflow-x:auto;overflow-y:auto;max-height:72vh} +table{width:100%;border-collapse:separate;border-spacing:0;font-size:0.78rem;white-space:nowrap;table-layout:fixed} +thead{position:sticky;top:0;z-index:10} +thead th{padding:10px 10px;text-align:center;font-weight:600;font-size:0.78rem;color:var(--th-color);text-transform:uppercase;letter-spacing:0.3px;background:var(--th-bg);border-bottom:1px solid var(--th-border);cursor:pointer;user-select:none} +thead th:hover{color:var(--hl2)} +thead th .sa{display:inline-block;margin-left:2px;font-size:0.55rem;opacity:0.2} +thead th:hover .sa{opacity:0.5} +thead th.sa-up .sa::after{content:'▲';color:var(--hl);opacity:1} +thead th.sa-dn .sa::after{content:'▼';color:var(--hl);opacity:1} +thead th.sticky{position:sticky;z-index:11;background:var(--th-bg)} +thead th.sticky-l{left:0;border-right:1px solid var(--th-border)} +.sticky-left-offset{position:sticky;z-index:2;background:var(--bg);border-right:1px solid var(--bd);box-shadow:1px 0 4px var(--sh1)} +thead .sticky-left-offset{background:var(--th-bg);border-bottom:1px solid var(--th-border);border-right:1px solid var(--th-border)} +tbody td{padding:9px 10px;border-bottom:1px solid var(--bd2);color:var(--fg2);text-align:center;font-size:0.82rem} +tbody tr:hover{background:rgba(127,119,221,0.04)} +tbody tr.re{background:rgba(127,119,221,0.015)} +tbody td.sticky{position:sticky;z-index:2;background:var(--bg)} +tbody td.sticky-l{left:0;border-right:1px solid var(--bd);box-shadow:1px 0 4px var(--sh1)} +tbody tr:hover td.sticky{background:var(--bg2)} + +.tw{position:relative;margin:0 20px 10px;border:1px solid var(--bd2);border-radius:10px;overflow:hidden;background:var(--bg2)} +.tw::after{content:'';position:absolute;top:0;right:0;bottom:0;width:50px;pointer-events:none;z-index:4;background:linear-gradient(to right,transparent,var(--bg2) 80%)} +.grp-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 8px 3px 10px;border-radius:6px;border:1px solid var(--bd);background:rgba(127,119,221,0.06);font-size:0.75rem;cursor:pointer;transition:.15s;user-select:none} +.grp-chip:hover{border-color:var(--hl)} +.grp-chip .ck{width:12px;height:12px;border-radius:3px;border:1.5px solid var(--fg4);display:flex;align-items:center;justify-content:center;font-size:0.5rem;transition:.15s;flex-shrink:0} +.grp-chip.active .ck{background:var(--hl);border-color:var(--hl)} +.grp-chip.active .ck::after{content:'✓';color:#fff;font-weight:700} +.grp-chip .rm{width:16px;height:16px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:0.65rem;opacity:0.4;transition:.15s;margin-left:2px} +.grp-chip .rm:hover{opacity:1;background:rgba(212,83,126,0.15);color:#ED93B1} +.grp-chip.inactive{opacity:0.5;border-style:dashed} +.grp-menu-item{padding:6px 12px;border-radius:4px;cursor:pointer;font-size:0.78rem;color:var(--fg3);transition:.1s;white-space:nowrap} +.grp-menu-item:hover{background:rgba(127,119,221,0.08);color:var(--fg)} + +/* collapsed column hint */ +.col-collapsed{width:28px!important;min-width:28px!important;max-width:28px!important;overflow:hidden;padding:0!important;font-size:0.6rem;color:var(--fg4);cursor:pointer;user-select:none;border-bottom:1px solid var(--bd2)} +.col-collapsed:hover{background:rgba(127,119,221,0.05)} +.tag{display:inline-block;padding:2px 8px;border-radius:50px;font-size:0.65rem;font-weight:500} +.tg-green{background:rgba(29,158,117,0.12);color:#5DCAA5} +.tg-red{background:rgba(212,83,126,0.12);color:#ED93B1} +.tg-amber{background:rgba(212,168,83,0.12);color:#EFC87E} +.tg-blue{background:rgba(55,138,221,0.12);color:#85B7EB} +.tg-gray{background:rgba(136,135,128,0.12);color:#B4B2A9} + +/* ===== Group rows ===== */ +tr.gr{background:rgba(127,119,221,0.04);cursor:pointer} +tr.gr:hover{background:rgba(127,119,221,0.08)} +tr.gr td{padding:7px 12px;font-weight:500;color:var(--hl2);font-size:0.76rem;text-align:left!important} +tr.gr .gt{display:inline-block;width:16px;text-align:center;margin-right:6px;font-size:0.65rem;opacity:0.5} +tr.gr2{background:rgba(127,119,221,0.025);cursor:pointer} +tr.gr2:hover{background:rgba(127,119,221,0.06)} +tr.gr2 td{padding:6px 12px;font-weight:400;color:var(--fg3);font-size:0.74rem;text-align:left!important} +tr.gr2 .gt{display:inline-block;width:16px;text-align:center;margin-right:6px;font-size:0.6rem;opacity:0.4;margin-left:20px} +tr.gr3{background:rgba(127,119,221,0.015);cursor:pointer} +tr.gr3:hover{background:rgba(127,119,221,0.04)} +tr.gr3 td{padding:5px 12px;font-weight:400;color:var(--fg4);font-size:0.72rem;text-align:left!important} +tr.gr3 .gt{display:inline-block;width:16px;text-align:center;margin-right:6px;font-size:0.55rem;opacity:0.3;margin-left:40px} +@keyframes fadeRow{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}} +tbody tr.data-row{animation:fadeRow .2s ease-out} +tr.ch.h{display:none} +.no-result{display:none;text-align:center;padding:40px 20px;color:var(--fg4)} +.no-result.show{display:block} +.ft{display:flex;justify-content:space-between;align-items:center;padding:0 20px 16px;flex-wrap:wrap;gap:6px} +.ft-info{color:var(--fg4);font-size:0.75rem} + +@media(max-width:768px){ + .sidebar{position:fixed;top:0;right:0;z-index:100;box-shadow:-4px 0 24px rgba(0,0,0,0.4)} + .sidebar.open{width:100%} + .summary{grid-template-columns:repeat(2,1fr);padding:8px 12px 0} + .report-desc{padding:4px 12px 6px} + .flt{padding:0 12px 8px} + .flt-g{flex:1;min-width:calc(50% - 6px)} + .tb{padding:0 12px 6px} + .tw{margin:0 12px 8px} + .header{padding:12px 12px 8px} + .ft{padding:0 12px 14px} + .header h1{font-size:1rem} +} + -
-

{{REPORT_NAME}}

-
- 加载中... - -
+
+ +
+
+
+
+

销售业绩总览

+
2026年5月 · 28个字段 · 30条记录 · 测试冻结用
+
+
+ + +
+
-
- -- - +
+
📊 加载中...
+ + +
+
+
+
+
+
+
- -
← 左右滑动查看更多 →
- -
-
-
-
正在加载数据...
-
+
+
+
+
+ 分组 +
+
+ + +
+
- +
+
+ + +
+
+ +
+
- +// ===== N-level Group Data ===== +function grpData(fd,fields){ + function buildLevel(items,level,fAncestors){ + fAncestors=fAncestors||[]; + // Last level - just show items, exit early to avoid fields[level] access + if(level>=fields.length||level>=4){ + const out=[]; + items.forEach((r,i)=>out.push({...r,_data:true,_path:[],_i:i,_ancestors:fAncestors})); + return out; + } + const key=fields[level].key; + const groups={}; + items.forEach(r=>{const v=r[key];if(!groups[v])groups[v]={items:[]};groups[v].items.push(r)}); + const out=[]; + Object.keys(groups).sort().forEach(k=>{ + const myAncestors=[...fAncestors,k]; + const path=out.length>0&&out[out.length-1]._isGroup?[...(out[out.length-1]._path||[])]:[]; + const myPath=path; + out.push({_isGroup:true,_key:k,_path:myPath,_level:level,_count:groups[k].items.length,_ancestors:fAncestors}); + const children=buildLevel(groups[k].items,level+1,myAncestors); + children.forEach(c=>{if(!c._parentKey)c._parentKey=k}); + out.push(...children); + }); + return out; + } + return buildLevel(fd,0,[]); +} +function isAnyAncestorCollapsed(ancestors, grpExp){ + if(!ancestors||!ancestors.length)return false; + return ancestors.some(a=>grpExp[a]===false); +} +function toggleGroup(k){ + const key=typeof k==='string'?k:k; + grpExp[key]=!grpExp[key]; + const expanded=grpExp[key]; + const parentRow=document.querySelector(`tr[data-group="${key}"]`); + if(parentRow){ + const icon=parentRow.querySelector('.gt'); + if(icon)icon.textContent=expanded?'▼':'▶'; + } + // Find all descendants via data-ancestors and toggle them + document.querySelectorAll('tr.data-row').forEach(r=>{ + const a=r.dataset.ancestors; + if(!a)return; + const list=a.split(','); + if(list.includes(key)){ + if(expanded){ + // When expanding, check if any OTHER ancestor is still collapsed + const stillCollapsed=list.some(a=>a!==key&&grpExp[a]===false); + r.style.display=stillCollapsed?'none':''; + }else{ + r.style.display='none'; + } + } + }); +} + +// ===== Sort ===== +function sortBy(i){ + if(sCol===i){sAsc=!sAsc}else{sCol=i;sAsc=true} + const k=COLS[i].k; + data.sort((a,b)=>{ + const va=a[k],vb=b[k]; + if(typeof va==='number')return sAsc?va-vb:vb-va; + const na=parseFloat(va),nb=parseFloat(vb); + if(!isNaN(na)&&!isNaN(nb))return sAsc?na-nb:nb-na; + return sAsc?String(va).localeCompare(String(vb),'zh'):String(vb).localeCompare(String(va),'zh'); + }); + r(); +} + +// ===== Freeze ===== +function fzPin(col){ + fzCount=(fzCount===col+1)?0:col+1; + r(); +} +function getFrozenLeft(i, groupHideKeys){ + if(i>=fzCount)return '0px'; + let w=50; // # column width + for(let j=0;j` +
+ ≡ + + ${c.k} +
+ `).join(''); + // Update visible count and toggle button + const visCnt=COLS.filter((_,i)=>colVis[i]).length; + const cntEl=$('visColCnt'); + if(cntEl)cntEl.textContent=visCnt; + const togEl=$('showAllToggle'); + if(togEl)togEl.textContent=visCnt===COLS.length?'恢复默认':'全部展开'; + // Attach drag event listeners + document.querySelectorAll('.drag-h').forEach(h=>{ + h.addEventListener('dragstart',onDragStart); + h.addEventListener('dragend',onDragEnd); + }); + document.querySelectorAll('.col-check').forEach(d=>{ + d.addEventListener('dragover',onDragOver); + d.addEventListener('dragleave',onDragLeave); + d.addEventListener('drop',onDrop); + }); +} +function onDragStart(e){ + dragIdx=parseInt(e.target.dataset.ci); + e.dataTransfer.effectAllowed='move'; + e.dataTransfer.setData('text/plain',dragIdx); + setTimeout(()=>e.target.closest('.col-check').classList.add('dragging'),0); +} +function onDragEnd(e){ + dragIdx=-1; + document.querySelectorAll('.col-check').forEach(d=>{ + d.classList.remove('dragging','drag-over-top','drag-over-bottom'); + }); +} +function onDragOver(e){ + e.preventDefault(); + e.dataTransfer.dropEffect='move'; + const item=e.target.closest('.col-check'); + if(!item||dragIdx<0)return; + const targetIdx=parseInt(item.dataset.ci); + if(targetIdx===dragIdx)return; + const rect=item.getBoundingClientRect(); + const mid=rect.top+rect.height/2; + const isAbove=e.clientYd.classList.remove('drag-over-top','drag-over-bottom')); + item.classList.add(isAbove?'drag-over-top':'drag-over-bottom'); +} +function onDragLeave(e){ + const item=e.target.closest('.col-check'); + if(item)item.classList.remove('drag-over-top','drag-over-bottom'); +} +function onDrop(e){ + e.preventDefault(); + const from=parseInt(e.dataTransfer.getData('text/plain')); + const target=e.target.closest('.col-check'); + if(!target||from<0)return; + const to=parseInt(target.dataset.ci); + if(from===to){onDragEnd(e);return} + const rect=target.getBoundingClientRect(); + const mid=rect.top+rect.height/2; + const insertAfter=e.clientY>=mid; + reorderCol(from,to,insertAfter); + onDragEnd(e); +} +function reorderCol(from,to,insertAfter){ + // Calculate actual target position + let targetPos=to; + if(!insertAfter)targetPos=to>from?to-1:to; + + const col=COLS.splice(from,1)[0]; + const vis=colVis.splice(from,1)[0]; + COLS.splice(targetPos,0,col); + colVis.splice(targetPos,0,vis); + + buildColCheck(); + r(); +} +function togCol(i){ + colVis[i]=!colVis[i]; + document.getElementById('cc'+i).checked=colVis[i]; + savedColVis=null; + r(); +} +function togAllCols(){ + const vis=COLS.filter((_,i)=>colVis[i]).length; + if(vis===COLS.length){ + if(savedColVis)colVis=[...savedColVis]; + else colVis=COLS.map((_,i)=>i<10); + savedColVis=null; + }else{ + savedColVis=[...colVis]; + colVis=COLS.map(()=>true); + } + buildColCheck(); + r(); +} + +function togSide(){$('sb').classList.toggle('open')} +function togTheme(){dark=!dark;document.body.classList.toggle('light',!dark);$('themeBtn').textContent=dark?'🌓':'☀️'} + +function expXls(){ + const fd=flt(); + const hd=COLS.map(c=>c.l); + const rows=fd.map(r=>COLS.map(c=>r[c.k])); + const ws=XLSX.utils.aoa_to_sheet([hd,...rows]); + const wb=XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb,ws,'报表'); + XLSX.writeFile(wb,`报表_${new Date().toISOString().slice(0,10)}.xlsx`); +} + +// ===== 数据源弹窗 ===== +function openDataModal(){ + const ed=$('dataEditor'); + ed.value=JSON.stringify(REPORT_CONFIG,null,2); + $('dataModal').style.display='flex'; + ed.focus(); +} +function closeDataModal(){ + $('dataModal').style.display='none'; +} +function saveDataConfig(){ + const ed=$('dataEditor'); + let cfg; + try{cfg=JSON.parse(ed.value)}catch(e){alert('JSON 格式错误:'+e.message);return} + // Merge into REPORT_CONFIG + Object.assign(REPORT_CONFIG,cfg); + // Re-init from config + COLS.length=0;COLS.push(...REPORT_CONFIG.columns); + D.length=0;D.push(...REPORT_CONFIG.data); + data=D.map(r=>({...r})); + colVis=COLS.map((_,i)=>i({...g,active:true})); + grpExp={}; + sCol=6;sAsc=false; + fzCount=0; + closeDataModal(); + init(); +} +// Click backdrop to close +document.addEventListener('click',function(e){ + const m=$('dataModal'); + if(m&&e.target===m)closeDataModal(); +}); + diff --git a/yuthon_ai_agent/views/ai_report_views.xml b/yuthon_ai_agent/views/ai_report_views.xml index 055b5319f..1d06a07b4 100644 --- a/yuthon_ai_agent/views/ai_report_views.xml +++ b/yuthon_ai_agent/views/ai_report_views.xml @@ -50,6 +50,7 @@ + @@ -140,6 +141,7 @@ placeholder="[['state','=','sale']]"/> + @@ -151,6 +153,10 @@ + + +