ai模块测试

This commit is contained in:
李鹏宇
2026-05-29 09:08:29 +08:00
parent a84d33599b
commit 5e719c62f0
6 changed files with 890 additions and 263 deletions
+55 -5
View File
@@ -32,7 +32,7 @@ class AiReportController(http.Controller):
@http.route('/ai/report/<string:token>', 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')
+38 -4
View File
@@ -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}'}
+55 -12
View File
@@ -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 {
+21 -4
View File
@@ -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)
@@ -1,270 +1,747 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{REPORT_NAME}}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: #f0f2f5;
color: #333;
font-size: 14px;
padding-bottom: env(safe-area-inset-bottom);
}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>{{REPORT_TITLE}} · 动态报表</title>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"Inter","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;min-height:100vh;transition:background .3s,color .3s}
/* 顶部标题栏 */
.header {
background: linear-gradient(135deg, #1a73e8 0%, #4285f4 100%);
color: white;
padding: 20px 16px 16px;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.header h1 {
font-size: 18px;
font-weight: 600;
margin-bottom: 6px;
line-height: 1.4;
}
.header .meta {
font-size: 12px;
opacity: 0.85;
display: flex;
justify-content: space-between;
align-items: center;
}
:root{--bg:#0b0b0f;--bg2:#14141c;--bg3:#1a1a28;--bg4:#111118;--fg:#e8e6e3;--fg2:#d0ceca;--fg3:#9a97a0;--fg4:#6b6973;--bd:rgba(127,119,221,0.1);--bd2:rgba(127,119,221,0.06);--hl:#7F77DD;--hl2:#AFA9EC;--gr1:linear-gradient(135deg,#14141c,#1a1a28);--gr2:linear-gradient(135deg,#AFA9EC,#5DCAA5);--sh1:rgba(0,0,0,0.15);--pin-off:#6b6973;--pin-on:#7F77DD;--th-bg:#1a1a28;--th-border:rgba(127,119,221,0.15);--th-color:#b8b5c0}
.light{--bg:#f0ede8;--bg2:#faf8f5;--bg3:#f5f2ec;--bg4:#fdfcfa;--fg:#2a2826;--fg2:#4a4844;--fg3:#7a7770;--fg4:#a09d96;--bd:rgba(127,119,221,0.1);--bd2:rgba(127,119,221,0.05);--hl:#6B63C9;--hl2:#6B63C9;--gr1:linear-gradient(135deg,#faf8f5,#f0ede8);--gr2:linear-gradient(135deg,#6B63C9,#1D9E75);--sh1:rgba(0,0,0,0.04);--pin-off:#c5c2bb;--pin-on:#6B63C9;--th-bg:#e8e4de;--th-border:rgba(127,119,221,0.1);--th-color:#5a5854}
/* 工具栏 */
.toolbar {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
background: white;
border-bottom: 1px solid #e8e8e8;
}
.toolbar .count {
font-size: 13px;
color: #666;
}
.toolbar .refresh-btn {
background: #1a73e8;
color: white;
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: opacity 0.2s;
}
.toolbar .refresh-btn:active { opacity: 0.7; }
.toolbar .refresh-btn.loading { opacity: 0.6; pointer-events: none; }
body{background:var(--bg);color:var(--fg)}
.app{display:flex;min-height:100vh}
.main{flex:1;min-width:0;display:flex;flex-direction:column}
.sidebar{width:0;overflow:hidden;background:var(--bg4);border-left:1px solid var(--bd);transition:width .25s;flex-shrink:0}
.sidebar.open{width:300px}
.sidebar-inner{width:300px;padding:20px 18px;overflow-y:auto;height:100vh}
/* 表格容器 */
.table-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
background: white;
margin: 0;
}
table {
width: 100%;
min-width: 600px;
border-collapse: collapse;
font-size: 13px;
}
thead {
background: #fafafa;
position: sticky;
top: 0;
}
th {
padding: 12px 10px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #e8e8e8;
white-space: nowrap;
}
td {
padding: 10px;
border-bottom: 1px solid #f0f0f0;
word-break: break-all;
max-width: 200px;
}
tr:hover td { background: #f8f9fa; }
tr:last-child td { border-bottom: none; }
.header{background:var(--gr1);border-bottom:1px solid var(--bd);padding:18px 20px 12px}
.header-top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
.header h1{font-size:1.15rem;font-weight:600}
.header .sub{color:var(--fg3);font-size:0.78rem;margin-top:2px}
.header-right{text-align:right;font-size:0.72rem;color:var(--fg4);white-space:nowrap}
/* 序号列 */
.row-num {
color: #999;
font-size: 12px;
width: 36px;
text-align: center;
}
/* 加载状态 */
.loading-box {
text-align: center;
padding: 60px 20px;
color: #999;
}
.loading-box .spinner {
width: 32px; height: 32px;
border: 3px solid #e8e8e8;
border-top-color: #1a73e8;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ===== Summary ===== */
.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;padding:16px 20px 4px}
.card{background:var(--bg2);border:1px solid var(--bd);border-radius:12px;padding:16px 18px}
.card .l{font-size:0.75rem;color:var(--fg4);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px}
.card .v{font-size:1.4rem;font-weight:700;background:var(--gr2);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;margin:2px 0 4px}
.card .c{font-size:0.72rem;color:var(--fg4)}
.report-desc{padding:4px 20px 6px;font-size:0.78rem;color:var(--fg3);display:flex;gap:12px;flex-wrap:wrap;line-height:1.6}
.report-desc{padding:4px 20px 8px;font-size:0.72rem;color:var(--fg4);display:flex;gap:8px;flex-wrap:wrap}
/* 空数据 */
.empty-box {
text-align: center;
padding: 60px 20px;
color: #999;
}
.empty-box .icon { font-size: 48px; margin-bottom: 12px; }
.flt{display:flex;flex-wrap:wrap;gap:6px;padding:0 20px 8px}
.flt-g{display:flex;align-items:center;gap:4px;background:var(--bg2);border:1px solid var(--bd2);border-radius:6px;padding:3px 8px}
.flt-g label{font-size:0.7rem;color:var(--fg4);white-space:nowrap}
.flt-g input,.flt-g select{padding:4px 6px;background:transparent;border:none;color:var(--fg);font-size:0.8rem;outline:none;min-width:65px;font-family:inherit}
.flt-g select option{background:var(--bg2);color:var(--fg)}
.flt-g input::placeholder{color:var(--fg4);font-size:0.75rem}
.flt-g.wide{min-width:120px;flex:0.5}
/* 底部信息 */
.footer {
padding: 16px;
text-align: center;
font-size: 12px;
color: #999;
}
.tb{display:flex;justify-content:space-between;align-items:center;gap:6px;padding:0 20px 6px;flex-wrap:wrap}
.tb-l,.tb-r{display:flex;gap:5px;flex-wrap:wrap;align-items:center}
.btn{padding:5px 12px;border-radius:6px;border:1px solid var(--bd);background:transparent;color:var(--fg3);font-size:0.75rem;cursor:pointer;transition:.2s;font-family:inherit;display:inline-flex;align-items:center;gap:4px}
.btn:hover{border-color:var(--hl);color:var(--fg);background:rgba(127,119,221,0.05)}
.btn.act{background:rgba(127,119,221,0.12);border-color:var(--hl);color:var(--hl2)}
.btn-excel{padding:5px 14px;border:none;border-radius:6px;background:linear-gradient(135deg,#1D9E75,#0F6E56);color:#fff;font-size:0.75rem;cursor:pointer;transition:.2s;font-family:inherit}
.btn-excel:hover{box-shadow:0 4px 12px rgba(29,158,117,0.25)}
.cnt{font-size:0.75rem;color:var(--fg4)}
.theme-btn{width:28px;height:28px;border-radius:50%;border:1px solid var(--bd);background:var(--bg2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:0.8rem;transition:.2s}
.theme-btn:hover{border-color:var(--hl)}
.sb-title{font-size:0.85rem;font-weight:600;margin-bottom:16px}
.sb-sec{margin-bottom:18px}
.sb-sec-title{font-size:0.72rem;color:var(--fg4);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}
.col-check{display:flex;align-items:center;gap:6px;padding:5px 6px;border-radius:5px;transition:.15s}
.col-check:hover{background:rgba(127,119,221,0.05)}
.col-check input[type=checkbox]{width:14px;height:14px;accent-color:var(--hl);cursor:pointer}
.col-check label{font-size:0.78rem;color:var(--fg3);cursor:pointer;flex:1}
.col-check .col-tag{font-size:0.65rem;padding:1px 6px;border-radius:4px;background:rgba(127,119,221,0.08);color:var(--hl)}
.drag-h{display:inline-flex;align-items:center;justify-content:center;width:24px;height:26px;cursor:grab;color:var(--fg3);font-size:1rem;flex-shrink:0;border-radius:4px;transition:.15s;user-select:none;font-weight:600}
.drag-h:hover{color:var(--hl2);background:rgba(127,119,221,0.12)}
.drag-h:active{cursor:grabbing}
.col-check.dragging{opacity:0.4;background:rgba(127,119,221,0.05)}
.col-check.drag-over-top{border-top:2px solid var(--hl)}
.col-check.drag-over-bottom{border-bottom:2px solid var(--hl)}
/* 滚动提示 */
.scroll-hint {
text-align: center;
font-size: 11px;
color: #aaa;
padding: 6px;
background: white;
border-bottom: 1px solid #f0f0f0;
display: none;
}
@media (max-width: 640px) {
.scroll-hint { display: block; }
}
</style>
/* ===== 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}
}
</style>
</head>
<body>
<div class="header">
<h1 id="reportTitle">{{REPORT_NAME}}</h1>
<div class="meta">
<span id="updateTime">加载中...</span>
<span id="dataSource"></span>
</div>
<div class="app">
<div class="main">
<div class="header">
<div class="header-top">
<div>
<h1 id="rptTitle">销售业绩总览</h1>
<div class="sub" id="rptSub">2026年5月 · 28个字段 · 30条记录 · 测试冻结用</div>
</div>
<div class="header-right" style="display:flex;gap:8px;align-items:center">
<button class="theme-btn" onclick="togTheme()" id="themeBtn" title="切换主题">🌓</button>
<span id="rptTime"></span>
</div>
</div>
</div>
<div class="toolbar">
<span class="count" id="recordCount">--</span>
<button class="refresh-btn" id="refreshBtn" onclick="loadData()">
&#8635; 刷新
</button>
<div class="summary" id="sumArea"></div>
<div class="report-desc" id="descArea">📊 加载中...</div>
<div class="flt">
<div class="flt-g"><label>区域</label><input id="f0" placeholder="全部" oninput="rf()"></div>
<div class="flt-g"><label>销售</label><input id="f1" placeholder="全部" oninput="rf()"></div>
<div class="flt-g"><label>产品</label><select id="f2" onchange="rf()"><option value="">全部</option><option>ERP</option><option>AI</option><option>培训</option><option>定制</option><option>看板</option></select></div>
<div class="flt-g"><label>状态</label><select id="f3" onchange="rf()"><option value="">全部</option><option>超额</option><option>完成</option><option>进行</option><option>滞后</option></select></div>
<div style="display:flex;gap:4px;align-items:center">
<button class="btn" onclick="document.querySelectorAll('.flt-g input,.flt-g select').forEach(e=>e.value='');rf()">清空</button>
</div>
<div class="scroll-hint">&#8592; 左右滑动查看更多 &#8594;</div>
<div id="content">
<div class="loading-box">
<div class="spinner"></div>
<div>正在加载数据...</div>
</div>
</div>
<div class="flt" id="grpRow">
<div class="flt-g wide" style="min-width:100px;flex:0.3"><label>🔍</label><input id="fG" placeholder="全局搜索..." oninput="rf()" style="width:100%"></div>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1">
<span style="font-size:0.72rem;color:var(--fg4);white-space:nowrap">分组</span>
<div id="grpChips" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center"></div>
<div style="position:relative">
<button class="btn" onclick="showGrpMenu()" style="padding:3px 10px;font-size:0.75rem">+ 添加</button>
<div id="grpMenu" style="display:none;position:absolute;top:100%;left:0;z-index:50;background:var(--bg2);border:1px solid var(--bd);border-radius:8px;padding:6px;min-width:140px;box-shadow:0 8px 24px rgba(0,0,0,0.3);margin-top:4px"></div>
</div>
</div>
</div>
<div class="footer" id="footer"></div>
<div class="tb">
<div class="tb-l">
<button class="btn" onclick="togSide()" id="btnCfg">⚙️ 列配置</button>
<span class="cnt" id="recCnt"></span>
</div>
<div class="tb-r">
<button class="btn-excel" onclick="expXls()">⬇ 导出 Excel</button>
</div>
</div>
<script>
const DATA_URL = '{{DATA_URL}}';
<div class="tw"><div class="ts" id="ts">
<table><thead id="thd"></thead><tbody id="tbd"></tbody></table>
</div><div class="no-result" id="nr"><p>没有匹配的数据</p></div></div>
async function loadData() {
const btn = document.getElementById('refreshBtn');
btn.classList.add('loading');
btn.innerHTML = '&#8635; 加载中...';
<div class="ft">
<div class="ft-info" id="ftInfo"></div>
</div>
</div>
try {
const resp = await fetch(DATA_URL);
const data = await resp.json();
<div class="sidebar" id="sb">
<div class="sidebar-inner">
<div style="margin-bottom:14px;padding-bottom:12px;border-bottom:1px solid var(--bd)">
<div style="font-size:0.8rem;font-weight:600;margin-bottom:8px">数据源</div>
<div style="display:flex;gap:6px">
<button class="btn" onclick="openDataModal()" style="flex:1;justify-content:center;padding:6px">📋 查看/编辑</button>
</div>
</div>
<div class="sb-title">列配置
<span onclick="togSide()" style="float:right;cursor:pointer;color:var(--fg4);font-size:0.8rem">✕ 关闭</span>
</div>
<div class="sb-sec">
<div class="sb-sec-title" style="display:flex;justify-content:space-between;align-items:center">
<span>显示列(<span id="visColCnt">0</span>个)</span>
<span id="showAllToggle" style="font-size:0.72rem;cursor:pointer;color:var(--hl);padding:2px 8px;border-radius:4px;border:1px solid var(--hl)" onclick="togAllCols()">全部展开</span>
</div>
<div id="colCheckList"></div>
</div>
</div>
</div>
if (data.error) {
showError(data.error);
return;
}
</div>
renderTable(data);
document.getElementById('updateTime').textContent =
'更新: ' + new Date().toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
document.getElementById('recordCount').textContent =
'共 ' + data.count + ' 条数据';
} catch (e) {
showError('网络错误,请稍后重试');
} finally {
btn.classList.remove('loading');
btn.innerHTML = '&#8635; 刷新';
}
<!-- ===== 数据源编辑弹窗 ===== -->
<div id="dataModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;z-index:999;background:rgba(0,0,0,0.5);justify-content:center;align-items:flex-start;padding:40px 20px">
<div style="background:var(--bg2);border:1px solid var(--bd);border-radius:12px;width:100%;max-width:900px;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3)">
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--bd);flex-shrink:0">
<span style="font-size:0.9rem;font-weight:600">数据源配置</span>
<span onclick="closeDataModal()" style="cursor:pointer;color:var(--fg4);font-size:0.85rem">✕ 关闭</span>
</div>
<div style="padding:8px 18px;font-size:0.72rem;color:var(--fg4);border-bottom:1px solid var(--bd2);flex-shrink:0">
修改后点击保存,报表将重新渲染。配置结构:title / subtitle / columns / data / cards / groupBy / colorRules / filters
</div>
<textarea id="dataEditor" style="flex:1;padding:14px 18px;font-family:'Consolas','Fira Code',monospace;font-size:0.78rem;background:var(--bg4);color:var(--fg);border:none;outline:none;resize:none;tab-size:2;min-height:300px"></textarea>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 18px;border-top:1px solid var(--bd);flex-shrink:0">
<button class="btn" onclick="closeDataModal()">取消</button>
<button class="btn-excel" onclick="saveDataConfig()">💾 保存并刷新</button>
</div>
</div>
</div>
<script>
const REPORT_CONFIG = {{REPORT_CONFIG_JSON}};
const DATA_URL = '{{DATA_URL}}';
// ===== 从配置初始化 =====
const COLS=REPORT_CONFIG.columns;
const D=REPORT_CONFIG.data;
let data=D.map(r=>({...r}));
let colVis=COLS.map((_,i)=>i<REPORT_CONFIG.defaultVisible);
let sCol=6,sAsc=false;
let grpFields=REPORT_CONFIG.groupBy.map(g=>({...g,active:true}));
let grpExp={};
let fzCount=0;
let dark=true;
function init(){
$('rptTitle').textContent = REPORT_CONFIG.title || '报表';
data.sort((a,b)=>b.amount-a.amount);
initGrpExp();
buildColCheck();
buildGrpChips();
r();
}
init();
function r(){
const fd=flt();
const activeGrps=grpFields.filter(g=>g.active);
const dd=activeGrps.length>0?grpData(fd,activeGrps):fd;
renderT(dd,fd);
renderSum(fd);
}
function $(id){return document.getElementById(id)}
function rf(){r()}
function flt(){
const a=$('f0').value.trim().toLowerCase();
const b=$('f1').value.trim().toLowerCase();
const c=$('f2').value;
const d=$('f3').value;
const g=$('fG').value.trim().toLowerCase();
return data.filter(r=>{
if(a&&!r.region.toLowerCase().includes(a))return false;
if(b&&!r.person.toLowerCase().includes(b))return false;
if(c&&r.product!==c)return false;
if(d&&r.status!==d)return false;
if(g&&!Object.values(r).some(v=>String(v).toLowerCase().includes(g)))return false;
return true;
});
}
function renderT(dd,fd){
const th=$('thd'),tb=$('tbd'),nr=$('nr');
const groupHideKeys=grpFields.filter(g=>g.active).map(g=>g.key);
th.innerHTML='<tr><th class="sticky sticky-l" style="width:50px;text-align:center;font-size:0.68rem;color:var(--fg4);background:var(--th-bg);border-bottom:1px solid var(--th-border);cursor:default">#</th>'+COLS.map((c,i)=>{
let cls='';if(i===sCol)cls=sAsc?'sa-up':'sa-dn';
// Hide grouped field columns from header too, to match body column count
if(!colVis[i]||groupHideKeys.includes(c.k))return '';
let st='',stLeft='0px';
if(i<fzCount&&!groupHideKeys.includes(c.k)){
let calcLeft=50;
for(let j=0;j<i;j++){
if(colVis[j]&&j<fzCount&&!groupHideKeys.includes(COLS[j].k)){
calcLeft+=COLS[j].w;
}
}
st='sticky-left-offset';
stLeft=calcLeft+'px';
}
let pin=fzCount>i?'on':'';
return `<th class="${st} ${cls}" style="width:${c.w||90}px${st!=''?`;left:${stLeft}`:''}" onclick="sortBy(${i})">${c.l}<span class="sa"></span><span class="fz-pin ${pin}" onclick="event.stopPropagation();fzPin(${i})" title="冻结到此列">📌</span></th>`;
}).join('')+'</tr>';
function renderTable(data) {
const { fields, labels, records } = data;
if (!records || records.length === 0) {
document.getElementById('content').innerHTML =
'<div class="empty-box"><div class="icon">&#128196;</div><div>暂无数据</div></div>';
return;
if(!dd.length){nr.classList.add('show');tb.innerHTML=''}else{
nr.classList.remove('show');
tb.innerHTML=dd.map((r,i)=>{
// Group row
if(r._isGroup){
if(isAnyAncestorCollapsed(r._ancestors,grpExp))return '';
const ex=grpExp[r._key]!==false;
if(ex && grpExp[r._key]===undefined)grpExp[r._key]=true;
const cls=r._level===0?'gr':r._level===1?'gr2':'gr3';
const cntText=r._count>5?`${r._count}条`:'';
const indent=r._level>0?'&nbsp;'.repeat(r._level*4):'';
const groupHideKeys=grpFields.filter(g=>g.active).map(g=>g.key);
// Build individual tds so sums appear under the right columns
let firstVisIdx=-1;
for(let vi=0;vi<COLS.length;vi++){if(colVis[vi]&&!groupHideKeys.includes(COLS[vi].k)){firstVisIdx=vi;break;}}
let cells='<td style="width:50px;text-align:center;color:var(--fg4);font-size:0.7rem;background:var(--th-bg);border-right:1px solid var(--th-border);position:sticky;left:0;z-index:3">∑</td>';
cells+=COLS.map((c,i)=>{
if(!colVis[i]||groupHideKeys.includes(c.k))return '';
let st='';let stLeft='0px';
if(i<fzCount){
st='sticky-left-offset';
let calcLeft=50;
for(let j=0;j<i;j++){
if(colVis[j]&&j<fzCount&&!groupHideKeys.includes(COLS[j].k))calcLeft+=COLS[j].w;
}
let html = '<div class="table-wrapper"><table><thead><tr>';
html += '<th class="row-num">#</th>';
for (const f of fields) {
html += '<th>' + escapeHtml(labels[f] || f) + '</th>';
}
html += '</tr></thead><tbody>';
for (let i = 0; i < records.length; i++) {
const rec = records[i];
html += '<tr><td class="row-num">' + (i + 1) + '</td>';
for (const f of fields) {
let val = rec[f];
if (val === null || val === undefined || val === false) val = '';
if (Array.isArray(val)) val = val[1] || val[0] || '';
html += '<td>' + escapeHtml(String(val)) + '</td>';
}
html += '</tr>';
}
html += '</tbody></table></div>';
document.getElementById('content').innerHTML = html;
document.getElementById('footer').textContent =
'数据由 AI 助手生成 · 有效期 7 天';
stLeft=calcLeft+'px';
}
if(i===firstVisIdx&&firstVisIdx>=0){
const grpLeft=fzCount>0?stLeft:'50px';
const grpCls=fzCount>0?st:'sticky-left-offset';
return `<td class="${grpCls}" style="width:${c.w||90}px;left:${grpLeft};text-align:left;font-weight:500;color:var(--hl2)"><span class="gt">${ex?'▼':'▶'}</span>${indent}${r._key}${cntText?' <span style="color:var(--fg4);font-weight:400">'+cntText+'</span>':''}</td>`;
}
return `<td class="${st}" style="width:${c.w||90}px;${st!=''?`left:${stLeft}`:''}"></td>`;
}).join('');
const ancestorsAttr=r._ancestors&&r._ancestors.length?` data-ancestors="${r._ancestors.join(',')}"`:'';
return `<tr class="${cls} data-row" data-group="${r._key}"${ancestorsAttr} onclick="toggleGroup('${r._key}')">${cells}</tr>`;
}
// Check collapse ancestors
if(r._ancestors&&isAnyAncestorCollapsed(r._ancestors,grpExp))return '';
// Regular data row
const odd=(i%2)?'re':'';
const sa=r.status||'';
const tc=sa==='超额完成'||sa==='超额'?'tg-green':sa==='已完成'||sa==='完成'?'tg-blue':sa==='进行中'||sa==='进行'?'tg-amber':sa==='滞后'?'tg-red':'tg-gray';
// Get active group field keys to auto-hide
const groupHideKeys=grpFields.filter(g=>g.active).map(g=>g.key);
// Row number - restart within each group
const rowNum=dd.filter((x,j)=>j<=i && !x._isGroup && !(x._ancestors&&isAnyAncestorCollapsed(x._ancestors,grpExp))).length;
const ancestorsAttr=r._ancestors&&r._ancestors.length?` data-ancestors="${r._ancestors.join(',')}"`:'';
return `<tr class="${odd} data-row"${ancestorsAttr}><td style="width:50px;text-align:center;color:var(--fg4);font-size:0.72rem;background:var(--bg);border-right:1px solid var(--bd);position:sticky;left:0;z-index:3">${rowNum}</td>${COLS.map((c,j)=>{
if(!colVis[j]||groupHideKeys.includes(c.k))return '';
let v=r[c.k];
let st='',stLeft='0px';
if(j<fzCount){
st='sticky-left-offset';
let calcLeft=50;
for(let k=0;k<j;k++){
if(colVis[k]&&k<fzCount&&!groupHideKeys.includes(COLS[k].k))calcLeft+=COLS[k].w;
}
stLeft=calcLeft+'px';
}
if(st.includes('sticky-l'))st='sticky-left-offset';
let h=c.k==='status'?`<span class="tag ${tc}">${v}</span>`:v;
return `<td class="${st}" style="width:${c.w||90}px;${st!=''?`left:${stLeft}`:''}">${h}</td>`;
}).join('')}</tr>`;
}).filter(Boolean).join('');
}
$('recCnt').textContent=`${data.length}条/${fd.length}条`;
// Set min-width = total width of all visible columns + # column
let totalW=50; // # column
COLS.forEach((c,i)=>{if(colVis[i]&&!groupHideKeys.includes(c.k))totalW+=c.w||90});
const tbl=document.querySelector('#ts table');
if(tbl){
tbl.style.minWidth=totalW+'px';
}
}
function showError(msg) {
document.getElementById('content').innerHTML =
'<div class="empty-box"><div class="icon">&#9888;</div><div>' +
escapeHtml(msg) + '</div></div>';
}
function renderSum(fd){
// Conditional cards based on config
const cards=REPORT_CONFIG.cards;
if(cards&&cards.length){
let html='';
cards.forEach(c=>{
let val=0;
if(c.calc==='sum'){val=fd.reduce((s,r)=>s+(Number(r[c.field])||0),0);}
else if(c.calc==='distinct'){val=[...new Set(fd.map(r=>r[c.field]))].length;}
else if(c.calc==='count'){val=fd.length;}
const prefix=c.prefix||'';
const unit=c.unit||'';
const display=unit?prefix+(val/(unit==='万'?10000:1)).toFixed(1)+unit:prefix+val.toLocaleString();
html+=`<div class="card"><div class="l">${c.label}</div><div class="v">${display}</div><div class="c">${fd.length}条记录</div></div>`;
});
$('sumArea').innerHTML=html;
}else{
$('sumArea').innerHTML='';
}
$('ftInfo').textContent='';
$('rptTime').textContent=new Date().toLocaleString('zh-CN');
// Description
const cities=[...new Set(fd.map(r=>r.city))].length;
const regions=[...new Set(fd.map(r=>r.region))].length;
const persons=[...new Set(fd.map(r=>r.person))].length;
const totalAmt=fd.reduce((s,r)=>s+(r.amount||0),0);
const totalOrd=fd.reduce((s,r)=>s+(r.orders||0),0);
const activeGrps=grpFields.filter(g=>g.active);
let groupInfo='';
if(activeGrps.length>0)groupInfo=' · 分组:'+activeGrps.map(g=>g.label).join(' → ');
const now=new Date();
$('descArea').innerHTML=
`<span>📊 数据概览:${regions} 个区域 · ${cities} 个城市 · ${persons} 位销售 · ${fd.length} 笔订单,总销售额 ¥${(totalAmt/10000).toFixed(1)}万,${totalOrd} 单</span>`+
(groupInfo?`<span>📂${groupInfo}</span>`:'')+
`<span style="color:var(--fg4)">🕐 ${now.toLocaleDateString('zh-CN')} ${now.toLocaleTimeString('zh-CN')}</span>`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ===== Chip-based Grouping =====
function initGrpExp(){
grpExp={};
const act=grpFields.filter(g=>g.active);
if(!act.length)return;
const first=act[0];
[...new Set(data.map(r=>r[first.key]))].forEach(v=>grpExp[v]=true);
}
function buildGrpChips(){
const el=$('grpChips');
el.innerHTML=grpFields.map((g,i)=>{
const cls=g.active?'active':'inactive';
const lbl=COLS.find(c=>c.k===g.key)?.l||g.key;
return `<span class="grp-chip ${cls}" onclick="toggleGrpField(${i})" title="点击切换开关">`+
`<span class="ck"></span>${lbl}`+
`<span class="rm" onclick="event.stopPropagation();removeGrpField(${i})" title="移除">✕</span></span>`;
}).join('');
}
function showGrpMenu(){
const menu=$('grpMenu');
if(menu.style.display!=='block'){
const used=grpFields.map(g=>g.key);
const avail=COLS.filter(c=>!used.includes(c.k));
menu.innerHTML=avail.length?avail.map(c=>
`<div class="grp-menu-item" onclick="addGrpField('${c.k}')">+ ${c.l}</div>`
).join(''):'<div style="padding:6px 12px;font-size:0.75rem;color:var(--fg4)">已全部添加</div>';
menu.style.display='block';
}else{
menu.style.display='none';
}
}
document.addEventListener('click',function(e){
const menu=$('grpMenu');
if(menu&&!e.target.closest('#grpRow'))menu.style.display='none';
});
function addGrpField(key){
const label=COLS.find(c=>c.k===key)?.l||key;
grpFields.push({key,label,active:true});
grpExp={};
initGrpExp();
buildGrpChips();
$('grpMenu').style.display='none';
r();
}
function toggleGrpField(idx){
grpFields[idx].active=!grpFields[idx].active;
grpExp={};
initGrpExp();
buildGrpChips();
r();
}
function removeGrpField(idx){
grpFields.splice(idx,1);
grpExp={};
initGrpExp();
buildGrpChips();
r();
}
// 页面加载时获取数据
loadData();
</script>
// ===== 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<i;j++){
if(colVis[j]&&j<fzCount&&!groupHideKeys.includes(COLS[j].k)){
w+=COLS[j].w;
}
}
return w+'px';
}
// ===== Columns =====
let dragIdx=-1;
let savedColVis=null;
function buildColCheck(){
const el=$('colCheckList');
el.innerHTML=COLS.map((c,i)=>`
<div class="col-check" draggable="false" data-ci="${i}">
<span class="drag-h" draggable="true" data-ci="${i}" title="拖拽排序">≡</span>
<input type="checkbox" ${colVis[i]?'checked':''} onchange="togCol(${i})" id="cc${i}">
<label for="cc${i}">${c.l}</label><span class="col-tag">${c.k}</span>
</div>
`).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.clientY<mid;
document.querySelectorAll('.col-check').forEach(d=>d.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<REPORT_CONFIG.defaultVisible);
grpFields=REPORT_CONFIG.groupBy.map(g=>({...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();
});
</script>
</body>
</html>
@@ -50,6 +50,7 @@
<field name="access_token" readonly="1"/>
<field name="expire_date"/>
<field name="template_id"/>
<field name="params"/>
<field name="active"/>
</group>
</group>
@@ -140,6 +141,7 @@
placeholder="[['state','=','sale']]"/>
<field name="default_order"
placeholder="date_order desc"/>
<field name="report_config" placeholder='{"columns":[{"k":"name","l":"字段名","w":120}],"cards":[{"label":"汇总","field":"id","calc":"count"}],"groupBy":[{"key":"state","label":"状态"}],"defaultVisible":10,"colorRules":[],"filters":[]}'/>
</group>
<group string="其他">
<field name="sequence"/>
@@ -151,6 +153,10 @@
<field name="description" nolabel="1"
placeholder="描述此模板的用途,如:当用户查询销售订单列表时使用此模板"/>
</group>
<group string="参数定义">
<field name="param_definitions" nolabel="1"
placeholder='[{"name":"date_from","label":"开始日期","type":"date","domain_field":"date_order","op":">=","description":"查询起始日期"}]'/>
</group>
</sheet>
</form>
</field>