This commit is contained in:
李鹏宇
2026-06-10 16:57:46 +08:00
parent a12cedefd5
commit 21c9f25ed0
6 changed files with 265 additions and 4 deletions
@@ -12,7 +12,7 @@
<field name="field_labels">{"name":"订单号","partner_id":"客户","date_order":"下单日期","amount_total":"金额","state":"状态","user_id":"销售员"}</field>
<field name="default_order">date_order desc</field>
<field name="default_visible">5</field>
<field name="report_config"><![CDATA[{"columns":[{"k":"name","l":"订单号","w":130},{"k":"partner_id","l":"客户","w":120},{"k":"date_order","l":"下单日期","w":110},{"k":"amount_total","l":"金额","w":110},{"k":"state","l":"状态","w":80}],"cards":[{"label":"总金额","field":"amount_total","calc":"sum","prefix":"¥"},{"label":"订单数","field":"id","calc":"count"}],"groupBy":[{"key":"state","label":"状态"}],"defaultVisible":5,"colorRules":[{"field":"state","rules":[{"op":"eq","value":"sale","color":"#5DCAA5"},{"op":"eq","value":"draft","color":"#EFC87E"},{"op":"eq","value":"done","color":"#85B7EB"},{"op":"eq","value":"cancel","color":"#ED93B1"}]}],"filters":[{"field":"state","type":"select","label":"状态","options":["draft","sale","done","cancel"]}]}]]></field>
<field name="report_config"><![CDATA[{"columns":[{"k":"name","l":"订单号","w":130},{"k":"partner_id","l":"客户","w":120},{"k":"date_order","l":"下单日期","w":110},{"k":"amount_total","l":"金额","w":110},{"k":"state","l":"状态","w":80}],"cards":[{"label":"总金额","field":"amount_total","calc":"sum","prefix":"¥"},{"label":"订单数","field":"id","calc":"count"}],"groupBy":[{"key":"state","label":"状态"}],"defaultVisible":5,"colorRules":[{"field":"state","rules":[{"op":"eq","value":"sale","color":"#5DCAA5"},{"op":"eq","value":"draft","color":"#EFC87E"},{"op":"eq","value":"done","color":"#85B7EB"},{"op":"eq","value":"cancel","color":"#ED93B1"}]}],"filters":[{"field":"state","type":"select","label":"状态","options":["draft","sale","done","cancel"]}],"charts":[{"type":"pie","labelField":"state","calc":"count","title":"订单状态分布"},{"type":"bar","labelField":"state","valueField":"amount_total","calc":"sum","title":"各状态金额汇总"}]}]]></field>
<field name="param_definitions"><![CDATA[[{"name":"date_from","label":"开始日期","type":"date","domain_field":"date_order","op":">=","description":"查询起始日期"},{"name":"date_to","label":"结束日期","type":"date","domain_field":"date_order","op":"<=","description":"查询截止日期"}]]]></field>
<field name="description">当用户查询销售订单列表或销售业绩时使用此模板。支持按日期范围筛选。</field>
<field name="sequence">10</field>
@@ -26,7 +26,7 @@
<field name="field_labels">{"name":"线索名称","partner_name":"客户","expected_revenue":"预期收入","probability":"赢单率","stage_id":"阶段","create_date":"创建时间","user_id":"负责人"}</field>
<field name="default_order">create_date desc</field>
<field name="default_visible">6</field>
<field name="report_config"><![CDATA[{"columns":[{"k":"name","l":"线索名称","w":140},{"k":"partner_name","l":"客户","w":120},{"k":"expected_revenue","l":"预期收入","w":110},{"k":"probability","l":"赢单率","w":80},{"k":"stage_id","l":"阶段","w":100},{"k":"create_date","l":"创建时间","w":120}],"cards":[{"label":"预期总收入","field":"expected_revenue","calc":"sum","prefix":"¥"},{"label":"线索数","field":"id","calc":"count"}],"groupBy":[{"key":"stage_id","label":"阶段"}],"defaultVisible":6,"colorRules":[],"filters":[{"field":"probability","type":"input","label":"最低赢单率"}]}]]></field>
<field name="report_config"><![CDATA[{"columns":[{"k":"name","l":"线索名称","w":140},{"k":"partner_name","l":"客户","w":120},{"k":"expected_revenue","l":"预期收入","w":110},{"k":"probability","l":"赢单率","w":80},{"k":"stage_id","l":"阶段","w":100},{"k":"create_date","l":"创建时间","w":120}],"cards":[{"label":"预期总收入","field":"expected_revenue","calc":"sum","prefix":"¥"},{"label":"线索数","field":"id","calc":"count"}],"groupBy":[{"key":"stage_id","label":"阶段"}],"defaultVisible":6,"colorRules":[],"filters":[{"field":"probability","type":"input","label":"最低赢单率"}],"charts":[{"type":"bar","labelField":"stage_id","valueField":"expected_revenue","calc":"sum","title":"各阶段预期收入"},{"type":"doughnut","labelField":"stage_id","calc":"count","title":"线索阶段分布"}]}]]></field>
<field name="description">当用户查询销售线索列表或销售管道时使用此模板。</field>
<field name="sequence">20</field>
<field name="active" eval="True"/>
+20 -1
View File
@@ -69,6 +69,8 @@ class AiReportTemplate(models.Model):
help='列定义中已配置的字段,用于限制分组/筛选器的可选范围')
param_def_ids = fields.One2many('ai.report.template.param.def', 'template_id',
string='AI 参数定义', copy=True)
chart_ids = fields.One2many('ai.report.template.chart', 'template_id',
string='图表配置', copy=True)
default_visible = fields.Integer('默认可见列数', default=10)
@@ -88,7 +90,7 @@ class AiReportTemplate(models.Model):
# ===== Computed: report_config =====
@api.depends('column_ids', 'card_ids', 'group_by_ids',
'filter_ids', 'default_visible')
'filter_ids', 'default_visible', 'chart_ids')
def _compute_report_config(self):
for t in self:
cfg = {}
@@ -162,6 +164,23 @@ class AiReportTemplate(models.Model):
cfg['filters'] = flt
if t.default_visible:
cfg['defaultVisible'] = t.default_visible
charts = []
for ch in t.chart_ids:
lf_name = ch.label_field_name or (ch.label_field_id.name if ch.label_field_id else '')
vf_name = ch.value_field_name or (ch.value_field_id.name if ch.value_field_id else '')
if not lf_name:
continue
cd = {
'type': ch.chart_type,
'labelField': lf_name,
'calc': ch.calc_method,
'title': ch.title or '',
}
if vf_name:
cd['valueField'] = vf_name
charts.append(cd)
if charts:
cfg['charts'] = charts
t.report_config = json.dumps(cfg, ensure_ascii=False) if cfg else '{}'
def _inverse_report_config(self):
@@ -200,6 +200,51 @@ class AiReportTemplateParamDef(models.Model):
self.label = self.field_id.field_description
class AiReportTemplateChart(models.Model):
"""报表模板 - 图表配置"""
_name = 'ai.report.template.chart'
_description = '图表配置'
_order = 'sequence, id'
template_id = fields.Many2one('ai.report.template', string='模板',
required=True, ondelete='cascade')
model_id = fields.Many2one('ir.model', related='template_id.model_id', store=True)
chart_type = fields.Selection([
('bar', '柱状图'),
('line', '折线图'),
('pie', '饼图'),
('doughnut', '环形图'),
('polarArea', '玫瑰图'),
], string='图表类型', default='bar', required=True)
label_field_id = fields.Many2one('ir.model.fields', string='分组字段',
domain="[('model_id', '=', model_id)]", required=True,
help='作为图表分类轴的字段')
label_field_name = fields.Char('分组字段名',
compute='_compute_label_field_name', store=True)
value_field_id = fields.Many2one('ir.model.fields', string='数值字段',
domain="[('model_id', '=', model_id), ('ttype', 'in', ('integer', 'float', 'monetary'))]",
help='留空则使用计数')
value_field_name = fields.Char('数值字段名',
compute='_compute_value_field_name', store=True)
calc_method = fields.Selection([
('count', '计数'),
('sum', '求和'),
('avg', '平均值'),
], string='计算方式', default='count')
title = fields.Char('图表标题')
sequence = fields.Integer('排序', default=10)
@api.depends('label_field_id')
def _compute_label_field_name(self):
for r in self:
r.label_field_name = r.label_field_id.name if r.label_field_id else ''
@api.depends('value_field_id')
def _compute_value_field_name(self):
for r in self:
r.value_field_name = r.value_field_id.name if r.value_field_id else ''
class AiReportTemplateDomainLine(models.Model):
_name = 'ai.report.template.domain.line'
_description = '报表模板-查询条件行'
@@ -29,3 +29,5 @@ access_ai_report_template_param_def_user,参数定义-用户只读,model_ai_repo
access_ai_report_template_param_def_admin,参数定义-管理员,model_ai_report_template_param_def,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1
access_ai_report_template_domain_line_user,查询条件行-用户只读,model_ai_report_template_domain_line,yuthon_ai_agent.group_ai_agent_user,1,0,0,0
access_ai_report_template_domain_line_admin,查询条件行-管理员,model_ai_report_template_domain_line,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1
access_ai_report_template_chart_user,图表配置-用户只读,model_ai_report_template_chart,yuthon_ai_agent.group_ai_agent_user,1,0,0,0
access_ai_report_template_chart_admin,图表配置-管理员,model_ai_report_template_chart,yuthon_ai_agent.group_ai_agent_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
29 access_ai_report_template_param_def_admin 参数定义-管理员 model_ai_report_template_param_def yuthon_ai_agent.group_ai_agent_admin 1 1 1 1
30 access_ai_report_template_domain_line_user 查询条件行-用户只读 model_ai_report_template_domain_line yuthon_ai_agent.group_ai_agent_user 1 0 0 0
31 access_ai_report_template_domain_line_admin 查询条件行-管理员 model_ai_report_template_domain_line yuthon_ai_agent.group_ai_agent_admin 1 1 1 1
32 access_ai_report_template_chart_user 图表配置-用户只读 model_ai_report_template_chart yuthon_ai_agent.group_ai_agent_user 1 0 0 0
33 access_ai_report_template_chart_admin 图表配置-管理员 model_ai_report_template_chart yuthon_ai_agent.group_ai_agent_admin 1 1 1 1
@@ -5,6 +5,7 @@
<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>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.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}
@@ -32,6 +33,16 @@ body{background:var(--bg);color:var(--fg)}
.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)}
/* ===== Charts ===== */
.charts-wrap{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px;padding:0 20px 8px}
.chart-box{background:var(--bg2);border:1px solid var(--bd);border-radius:12px;padding:16px;position:relative}
.chart-box .chart-title{font-size:0.75rem;color:var(--fg4);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}
.chart-box canvas{max-height:260px}
.chart-ctrls{display:flex;flex-wrap:wrap;gap:6px;align-items:center;padding:0 20px 4px}
.chart-ctrls label{font-size:0.7rem;color:var(--fg4);white-space:nowrap}
.chart-ctrls select,.chart-ctrls input{padding:4px 8px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--fg);font-size:0.75rem;outline:none;font-family:inherit;cursor:pointer}
.chart-ctrls select option{background:var(--bg2);color:var(--fg)}
.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}
@@ -177,6 +188,7 @@ tr.ch.h{display:none}
<div class="summary" id="sumArea"></div>
<div class="report-desc" id="descArea">📊 加载中...</div>
<div class="charts-wrap" id="chartsArea"></div>
<div class="flt" id="fltArea">
<div class="flt-g wide"><label>🔍</label><input id="fG" placeholder="全局搜索..." oninput="rf()"></div>
@@ -260,7 +272,7 @@ 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 colVis=COLS.map(()=>true);
let sCol=6,sAsc=false;
let grpFields=REPORT_CONFIG.groupBy.map(g=>({...g,active:true}));
let grpExp={};
@@ -274,6 +286,7 @@ function init(){
initGrpExp();
buildColCheck();
buildGrpChips();
buildChartsUI();
r();
}
init();
@@ -284,10 +297,174 @@ function r(){
const dd=activeGrps.length>0?grpData(fd,activeGrps):fd;
renderT(dd,fd);
renderSum(fd);
renderCharts(fd);
}
function $(id){return document.getElementById(id)}
function rf(){r()}
// ===== Charts =====
let chartInstances=[];
const CHART_COLORS=['#7F77DD','#5DCAA5','#F39C12','#E74C3C','#3498DB','#9B59B6','#1ABC9C','#E67E22','#2ECC71','#E91E63'];
const CHART_COLORS_DARK=CHART_COLORS;
const CHART_COLORS_LIGHT=['#6B63C9','#1D9E75','#D68910','#C0392B','#2980B9','#8E44AD','#17A589','#D35400','#27AE60','#C2185B'];
function chartColors(){return dark?CHART_COLORS_DARK:CHART_COLORS_LIGHT}
function chartGridColor(){return dark?'rgba(255,255,255,0.04)':'rgba(0,0,0,0.05)'}
function chartTextColor(){return dark?'rgba(232,230,227,0.5)':'rgba(42,40,38,0.5)'}
function buildChartsUI(){
const charts=REPORT_CONFIG.charts||[];
const cols=COLS;
if(!charts.length){
// No pre-configured charts, show default quick-select
const numCols=cols.filter(c=>{
const vals=data.map(r=>parseFloat(r[c.k]));
return vals.some(v=>!isNaN(v));
});
if(!numCols.length){$('chartsArea').innerHTML='';return}
const txtCols=cols;
const html=`
<div class="chart-box" style="grid-column:1/-1;display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:12px 16px">
<span style="font-size:0.72rem;color:var(--fg4);white-space:nowrap">📊 快速图表:</span>
<select id="qcDimSel" onchange="qcRender()" style="padding:4px 8px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--fg);font-size:0.75rem;cursor:pointer;font-family:inherit">
<option value="">分组字段...</option>
${txtCols.map(c=>`<option value="${c.k}">${c.l}</option>`).join('')}
</select>
<select id="qcValSel" onchange="qcRender()" style="padding:4px 8px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--fg);font-size:0.75rem;cursor:pointer;font-family:inherit">
<option value="">数值字段...</option>
<option value="__count__">计数</option>
${numCols.map(c=>`<option value="${c.k}">${c.l}</option>`).join('')}
</select>
<select id="qcTypeSel" onchange="qcRender()" style="padding:4px 8px;background:var(--bg2);border:1px solid var(--bd);border-radius:6px;color:var(--fg);font-size:0.75rem;cursor:pointer;font-family:inherit">
<option value="bar">柱状图</option>
<option value="pie">饼图</option>
<option value="doughnut">环形图</option>
<option value="line">折线图</option>
<option value="polarArea">玫瑰图</option>
</select>
</div>
`;
$('chartsArea').innerHTML=html;
}else{
// Pre-configured charts from template
let html='';
charts.forEach((ch,i)=>{
html+=`<div class="chart-box"><div class="chart-title">${ch.title||'图表 '+ (i+1)}</div><canvas id="chart${i}" height="200"></canvas></div>`;
});
$('chartsArea').innerHTML=html;
}
}
function renderCharts(fd){
chartInstances.forEach(c=>c.destroy());
chartInstances=[];
const charts=REPORT_CONFIG.charts||[];
if(charts.length){
charts.forEach((ch,i)=>{
const el=document.getElementById('chart'+i);
if(!el)return;
const lf=ch.labelField, vf=ch.valueField;
const groups={};
fd.forEach(r=>{
const k=String(r[lf]||'(空)');
if(!groups[k])groups[k]=0;
if(vf&&ch.calc==='sum')groups[k]+=Number(r[vf])||0;
else if(vf&&ch.calc==='avg'){
if(!groups['_cnt_'+k]){groups['_cnt_'+k]=0;groups[k]=0}
groups[k]+=Number(r[vf])||0;
groups['_cnt_'+k]+=1;
}else groups[k]+=1;
});
// Calculate avg
if(vf&&ch.calc==='avg'){
Object.keys(groups).forEach(k=>{
if(k.startsWith('_cnt_'))return;
groups[k]=groups['_cnt_'+k]?groups[k]/groups['_cnt_'+k]:0;
});
Object.keys(groups).forEach(k=>{if(k.startsWith('_cnt_'))delete groups[k]});
}
const labels=Object.keys(groups).sort();
const values=labels.map(l=>groups[l]);
const isPie=ch.type==='pie'||ch.type==='doughnut'||ch.type==='polarArea';
const cfg={
type:ch.type,
data:{
labels,
datasets:[{
label:ch.title||(ch.calc==='count'?'计数':'数值'),
data:values,
backgroundColor:labels.map((_,j)=>chartColors()[j%chartColors().length]),
borderColor:isPie?'transparent':chartColors(),
borderWidth:isPie?0:1,
borderRadius:isPie?0:4,
}]
},
options:{
responsive:true,
maintainAspectRatio:false,
plugins:{
legend:isPie?{position:'right',labels:{color:chartTextColor(),padding:12,font:{size:11}}}:{display:false}
},
scales:isPie?{}:{x:{grid:{color:chartGridColor()},ticks:{color:chartTextColor(),font:{size:10}}},y:{grid:{color:chartGridColor()},ticks:{color:chartTextColor(),font:{size:10}}}}
}
};
chartInstances.push(new Chart(el,cfg));
});
}else if($('qcDimSel')&&$('qcDimSel').value&&$('qcValSel')&&$('qcValSel').value){
// Quick chart
const dim=$('qcDimSel').value;
const val=$('qcValSel').value;
const tp=$('qcTypeSel')?$('qcTypeSel').value:'bar';
if(!dim||!val)return;
const groups={};
fd.forEach(r=>{
const k=String(r[dim]||'(空)');
if(!groups[k])groups[k]=0;
groups[k]+=val==='__count__'?1:(Number(r[val])||0);
});
const labels=Object.keys(groups).sort();
const values=labels.map(l=>groups[l]);
// Ensure chart canvas exists
let canvas=$('qcCanvas');
if(!canvas){
const wrap=$('chartsArea').querySelector('.chart-box');
if(wrap){
const div=document.createElement('div');
div.style.height='260px';
div.style.marginTop='8px';
div.innerHTML='<canvas id="qcCanvas"></canvas>';
wrap.appendChild(div);
canvas=$('qcCanvas');
}
}
if(!canvas)return;
const isPie=tp==='pie'||tp==='doughnut'||tp==='polarArea';
const cfg={
type:tp,
data:{
labels,
datasets:[{
label:val==='__count__'?'计数':(COLS.find(c=>c.k===val)?.l||val),
data:values,
backgroundColor:labels.map((_,j)=>chartColors()[j%chartColors().length]),
borderColor:isPie?'transparent':chartColors(),
borderWidth:isPie?0:1,
borderRadius:isPie?0:4,
}]
},
options:{
responsive:true,
maintainAspectRatio:false,
plugins:{
legend:isPie?{position:'right',labels:{color:chartTextColor(),padding:12,font:{size:11}}}:{display:false}
},
scales:isPie?{}:{x:{grid:{color:chartGridColor()},ticks:{color:chartTextColor(),font:{size:10}}},y:{grid:{color:chartGridColor()},ticks:{color:chartTextColor(),font:{size:10}}}}
}
};
chartInstances.push(new Chart(canvas,cfg));
}
}
function qcRender(){r()}
function buildFilters(){
const fltArea=$('fltArea');
if(!fltArea)return;
+18
View File
@@ -223,6 +223,24 @@
</field>
</page>
<page string="图表" name="charts">
<field name="chart_ids" nolabel="1">
<tree string="图表配置" editable="bottom">
<field name="model_id" column_invisible="1"/>
<field name="title" placeholder="图表标题"/>
<field name="chart_type"/>
<field name="label_field_id"
placeholder="分组字段..."
options="{'no_create': True}"/>
<field name="value_field_id"
placeholder="数值字段(可空)..."
options="{'no_create': True}"/>
<field name="calc_method"/>
<field name="sequence" widget="handle"/>
</tree>
</field>
</page>
<page string="AI 参数" name="params" invisible="1">
<field name="param_def_ids" nolabel="1">
<tree string="参数定义" editable="bottom">