Merge pull request !12691 from 李鹏宇/dev_wang
This commit is contained in:
李鹏宇
2026-08-20 10:56:57 +00:00
committed by Gitee
3 changed files with 609 additions and 5 deletions
@@ -1,7 +1,7 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component, onMounted, useState } from "@odoo/owl";
import { Component, onMounted, onWillUnmount, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { standardActionServiceProps } from "@web/webclient/actions/action_service";
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
@@ -18,6 +18,11 @@ class YuthonDashboard extends Component {
keyword: "",
loading: false,
collapsedSections: {},
// 图表数据(纯原生 DOM/SVG 渲染,不依赖任何外部图表库)
chartSections: [], // [{id, title, maxValue, bars: [{label,value,rawValue,color,sectionId}]}]
chartPieSlices: [], // [{label,value,rawValue,color,sectionId,path,percent}](环形图用)
chartRankBars: [], // 横向排行条形图:所有可跳转卡片按绝对值降序
chartSummary: [], // 关键指标卡:从板块汇总提炼的对比数据
// 筛选器
// 多选:所属公司改为数组
selectCompany: [],
@@ -35,7 +40,6 @@ class YuthonDashboard extends Component {
this.config = this.props.action.context.report_config || {};
// 尝试从 sessionStorage 恢复看板状态(从子报表返回时避免重置)
let restored = false;
try {
const cached = sessionStorage.getItem("yuthon_dashboard_cache");
if (cached) {
@@ -53,13 +57,19 @@ class YuthonDashboard extends Component {
collapsedSections: snapshot.collapsedSections || this.state.collapsedSections,
});
sessionStorage.removeItem("yuthon_dashboard_cache");
restored = !!(snapshot.dashboardData);
}
} catch (e) { /* ignore */ }
onMounted(() => {
// 首次进入不自动查询,等用户选了查询条件后点击"查询"或切换筛选器时再查
// 如果已从缓存恢复数据,直接展示即可
if (this.state.dashboardData) {
this.prepareChartData();
}
});
onWillUnmount(() => {
// 纯原生渲染,无需销毁图表实例
});
}
@@ -85,6 +95,181 @@ class YuthonDashboard extends Component {
return "font-size: 22px; font-weight: 700; color: #1a1a1a;";
}
/* ========== 图表数据准备(纯原生,无外部图表库) ========== */
/**
* 根据看板数据准备图表数据
* 每个板块生成一组柱状图数据;额外生成综合饼图(SVG 扇区)数据
* 图表由 OWL 模板渲染,本方法只负责计算数据
*/
prepareChartData() {
const sections = this.state.dashboardData?.sections || [];
const palette = ["#1890ff", "#52c41a", "#faad14", "#f5222d", "#722ed1", "#13c2c2", "#eb2f96", "#fa541c"];
const chartSections = [];
const pieRaw = [];
sections.forEach((section) => {
// 只取有数值且可跳转的卡片生成图表,保持图表可交互
const data = (section.cards || [])
.filter((c) => c.action && !c.is_percent)
.map((c, idx) => ({
label: c.label,
value: Math.abs(Number(c.value) || 0),
rawValue: Number(c.value) || 0,
color: palette[idx % palette.length],
sectionId: section.id,
}));
if (data.length === 0) return;
const maxValue = Math.max(...data.map((d) => d.value), 1);
// 预计算柱子高度百分比,避免模板里用反引号表达式拼接样式
const bars = data.map((d) => ({
...d,
heightPct: ((d.value / maxValue) * 100).toFixed(1),
}));
chartSections.push({ id: section.id, title: section.title, maxValue, bars });
data.forEach((d) => { if (d.value !== 0) pieRaw.push(d); });
});
// 计算综合饼图的 SVG 扇区路径
const total = pieRaw.reduce((s, d) => s + d.value, 0) || 1;
const cx = 100, cy = 100, r = 90;
let angle = 0;
const slices = pieRaw.map((d) => {
const angleSize = (d.value / total) * 360;
const startAngle = angle;
const endAngle = angle + angleSize;
angle = endAngle;
const start = this._polarToCartesian(cx, cy, r, endAngle);
const end = this._polarToCartesian(cx, cy, r, startAngle);
const largeArc = angleSize <= 180 ? "0" : "1";
const path = `M ${cx} ${cy} L ${start.x.toFixed(2)} ${start.y.toFixed(2)} A ${r} ${r} 0 ${largeArc} 0 ${end.x.toFixed(2)} ${end.y.toFixed(2)} Z`;
return {
...d,
path,
percent: ((d.value / total) * 100).toFixed(1),
};
});
// 单扇区时 arc 两端点重合画不出圆,改为完整圆路径
if (slices.length === 1) {
slices[0].path = `M ${cx - r} ${cy} A ${r} ${r} 0 1 1 ${cx + r} ${cy} A ${r} ${r} 0 1 1 ${cx - r} ${cy} Z`;
}
// ---------- 横向排行条形图:所有可跳转卡片按绝对值降序 ----------
const allCards = [];
sections.forEach((section) => {
(section.cards || [])
.filter((c) => c.action && !c.is_percent)
.forEach((c) => {
const idx = section.cards.indexOf(c);
allCards.push({
label: c.label,
value: Math.abs(Number(c.value) || 0),
rawValue: Number(c.value) || 0,
color: palette[idx % palette.length],
sectionId: section.id,
cardLabel: c.label,
});
});
});
allCards.sort((a, b) => b.value - a.value);
const rankMax = Math.max(...allCards.map((d) => d.value), 1);
const chartRankBars = allCards.map((d) => ({
...d,
widthPct: ((d.value / rankMax) * 100).toFixed(1),
}));
// ---------- 关键指标卡:收入/支出/净收益等对比 ----------
const sumSection = (sid) => {
const sec = sections.find((s) => s.id === sid);
if (!sec) return 0;
return (sec.cards || [])
.filter((c) => c.action && !c.is_percent)
.reduce((s, c) => s + Math.abs(Number(c.value) || 0), 0);
};
// 尝试识别常见板块(按 id 或标题包含关键字)
const getSum = (sid, keywords) => {
let sec = sections.find((s) => s.id === sid);
if (!sec) {
sec = sections.find((s) => {
const t = (s.title || "").toLowerCase();
return keywords.some((k) => t.includes(k.toLowerCase()));
});
}
return sec ? sumSection(sec.id) : 0;
};
const incomeSum = getSum("income", ["收入", "income"]);
const expenseSum = getSum("expense", ["支出", "expense", "费用"]);
const depositSum = getSum("deposit", ["押金", "deposit"]);
const net = incomeSum - expenseSum;
const chartSummary = [
{ label: "经营收入合计", value: incomeSum, color: "#1890ff" },
{ label: "费用支出合计", value: expenseSum, color: "#f5222d" },
{ label: "净收益", value: net, color: net >= 0 ? "#52c41a" : "#f5222d" },
{ label: "押金合计", value: depositSum, color: "#722ed1" },
];
this.state.chartSections = chartSections;
this.state.chartPieSlices = slices;
this.state.chartRankBars = chartRankBars;
this.state.chartSummary = chartSummary;
}
/**
* 极坐标转直角坐标(SVG 饼图扇区用)
*/
_polarToCartesian(cx, cy, r, angleDeg) {
const a = (angleDeg - 90) * Math.PI / 180;
return { x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) };
}
/**
* 图表元素点击跳转(柱状图柱子 / 饼图扇区 / 饼图图例共用)
* 与卡片视图 onCardClick 逻辑完全一致,只是通过 ev.currentTarget.dataset 取参数
*/
onChartClick(ev) {
const target = ev.currentTarget;
const sectionId = target.dataset.sectionId;
const cardLabel = target.dataset.cardLabel;
if (!sectionId || !cardLabel) return;
const section = this.state.dashboardData?.sections.find((s) => s.id === sectionId);
if (!section) return;
const card = section.cards.find((c) => c.label === cardLabel);
if (!card || !card.action) return;
// 构建当前看板的筛选参数,透传给子报表
const dashboardFilter = {
date_str: this.state.dateStart || "", // "YYYY-MM"
company_id: this.state.selectCompany.map((item) => item.id),
company_name: this.state.selectCompany.map((item) => item.display_name).join(", "),
partner_id: this.state.selectPartner.map((item) => item.id),
partner_name: this.state.selectPartner.map((item) => item.display_name).join(", "),
condition: this.state.condition || "",
};
// 将当前看板完整状态缓存到 sessionStorage,返回时恢复,避免重置
try {
sessionStorage.setItem("yuthon_dashboard_cache", JSON.stringify({
dateStart: this.state.dateStart,
selectCompany: this.state.selectCompany,
selectPartner: this.state.selectPartner,
condition: this.state.condition,
domainUsers: this.state.domainUsers,
dashboardData: this.state.dashboardData,
collapsedSections: this.state.collapsedSections,
}));
} catch (e) { /* ignore */ }
this.actionService.doAction(card.action, {
additionalContext: {
...(card.action_context || {}),
dashboard_filter: dashboardFilter,
},
});
}
/* ========== 筛选器 domain & 回调 ========== */
getDomainCompany() {
@@ -182,6 +367,9 @@ class YuthonDashboard extends Component {
[params]
);
this.state.dashboardData = result;
// 查询完成后立即准备图表数据(卡片与图表同页展示)
await new Promise((resolve) => setTimeout(resolve, 0));
this.prepareChartData();
} catch (error) {
console.error("经营汇总看板查询失败:", error);
this.state.dashboardData = null;
@@ -90,6 +90,313 @@
}
}
/* ---------- 图表视图 ---------- */
.dashboard-chart-area {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 24px;
/* 图表中心标题 */
.chart-center-header {
display: flex;
align-items: baseline;
gap: 12px;
padding: 4px 0 0;
border-bottom: 2px solid #e8e8e8;
padding-bottom: 8px;
}
.chart-center-title {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
}
.chart-center-sub {
font-size: 12px;
color: #999;
}
/* 两列网格(响应式) */
.chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
/* 各板块柱状图专用网格:按可用宽度自动排成 2 列或 3 列,避免右侧大量留白 */
.chart-grid-bars {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
margin-top: 8px;
}
/* 环形图卡片:跨两列(约两个柱状图宽),不跟随 grid 拉伸高度,保持紧凑并与柱状图顶部对齐 */
.pie-chart-card {
align-self: start;
grid-column: span 2;
}
/* 跨整行的图表卡片 */
.chart-span-full {
grid-column: 1 / -1;
}
.dashboard-chart-wrapper {
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 10px;
padding: 16px 18px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.2s, transform 0.2s;
&:hover {
box-shadow: 0 6px 18px rgba(24, 144, 255, 0.12);
transform: translateY(-2px);
}
/* 图表标题 */
.chart-title {
font-weight: 600;
font-size: 14px;
color: #1a1a1a;
margin-bottom: 12px;
padding-left: 10px;
border-left: 4px solid #1890ff;
line-height: 1.2;
}
}
/* ---------- 横向排行条形图 ---------- */
.rank-chart {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 260px;
overflow-y: auto;
}
.rank-row {
display: flex;
align-items: center;
gap: 10px;
padding: 2px 4px;
border-radius: 4px;
&:hover {
background: #f5f9ff;
.rank-bar { filter: brightness(1.05); }
}
}
.rank-label {
width: 96px;
flex-shrink: 0;
font-size: 12px;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rank-track {
flex: 1;
height: 18px;
background: #f0f0f0;
border-radius: 9px;
overflow: hidden;
}
.rank-bar {
height: 100%;
border-radius: 9px;
transition: width 0.4s ease;
min-width: 4px;
}
.rank-value {
width: 110px;
flex-shrink: 0;
text-align: right;
font-size: 12px;
color: #1890ff;
font-weight: 600;
}
/* ---------- 环形图 ---------- */
.pie-chart-container {
display: flex;
align-items: center;
gap: 12px;
}
.donut-svg {
width: 130px;
height: 130px;
flex-shrink: 0;
path:hover {
opacity: 0.85;
}
}
.donut-center-label {
font-size: 11px;
fill: #999;
}
.donut-center-value {
font-size: 12px;
font-weight: 700;
fill: #1a1a1a;
}
.pie-legend {
flex: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px 10px;
align-content: start;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 4px;
border-radius: 3px;
font-size: 11px;
color: #333;
min-width: 0;
&:hover {
background: #e6f7ff;
}
}
.legend-color {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
}
.legend-label {
flex: 1;
min-width: 0;
white-space: normal;
word-break: break-word;
line-height: 1.2;
}
.legend-value {
color: #1890ff;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.legend-percent {
color: #999;
width: 36px;
text-align: right;
white-space: nowrap;
flex-shrink: 0;
}
/* ---------- 关键指标卡 ---------- */
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
height: 100%;
}
.summary-card {
background: #fafafa;
border-left: 4px solid #1890ff;
border-radius: 6px;
padding: 12px 14px;
display: flex;
flex-direction: column;
justify-content: center;
transition: all 0.2s;
&:hover {
background: #f0f7ff;
}
}
.summary-label {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 6px;
}
.summary-value {
font-size: 20px;
font-weight: 700;
}
/* ---------- 各板块柱状图 ---------- */
.bar-chart {
display: flex;
align-items: flex-end;
justify-content: space-around;
gap: 8px;
height: 240px;
padding: 8px 4px 0;
overflow-x: auto;
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 48px;
max-width: 80px;
height: 100%;
user-select: none;
&:hover {
.bar-rect {
opacity: 0.85;
filter: brightness(1.05);
}
}
}
.bar-value {
font-size: 11px;
color: #333;
margin-bottom: 4px;
white-space: nowrap;
}
.bar-track {
position: relative;
flex: 1;
width: 100%;
max-width: 36px;
display: flex;
align-items: flex-end;
}
.bar-rect {
width: 100%;
min-height: 3px;
border-radius: 4px 4px 0 0;
transition: all 0.2s;
background-image: linear-gradient(rgba(255, 255, 255, 0.25), rgba(0, 0, 0, 0.05));
}
.bar-label {
font-size: 12px;
color: #666;
margin-top: 6px;
max-width: 100%;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-align: center;
min-height: 30px;
}
}
/* 窄屏时图表网格改为单列,环形图图例也恢复单列避免过挤 */
@media (max-width: 1100px) {
.yuthon-dashboard .dashboard-chart-area .chart-grid {
grid-template-columns: 1fr;
}
.yuthon-dashboard .dashboard-chart-area .chart-grid-bars {
grid-template-columns: 1fr;
}
.yuthon-dashboard .dashboard-chart-area .pie-legend {
grid-template-columns: 1fr;
}
}
/* ---------- 滚动条美化 ---------- */
.dashboard-content::-webkit-scrollbar {
width: 6px;
@@ -36,7 +36,7 @@
<option value="">权属类型</option>
<option t-foreach="state.conditions" t-as="cond"
t-att-value="cond.value"
t-key="cond.name"
t-key="cond_index"
t-if="cond.value">
<t t-esc="cond.name"/>
</option>
@@ -72,6 +72,114 @@
<!-- ========== 报表内容区域 ========== -->
<div t-if="state.dashboardData" class="dashboard-content flex-grow-1 overflow-auto" style="padding: 16px 20px; min-height: 0;">
<!-- ========== 数据图表(置于卡片上方) ========== -->
<div t-if="state.chartSections.length > 0" class="dashboard-chart-area">
<!-- 图表中心标题 -->
<div class="chart-center-header">
<span class="chart-center-title"><i class="fa fa-area-chart" style="margin-right: 6px; color: #1890ff;"></i>数据图表</span>
<span class="chart-center-sub">点击任意图表元素可跳转对应报表</span>
</div>
<!-- 第一行:金额排行 TOP + 关键指标 -->
<div class="chart-grid">
<!-- 横向排行条形图 -->
<div class="dashboard-chart-wrapper" t-if="state.chartRankBars.length > 0">
<div class="chart-title"><i class="fa fa-sort-amount-desc" style="margin-right: 6px; color: #1890ff;"></i>金额排行 TOP</div>
<div class="rank-chart">
<t t-foreach="state.chartRankBars" t-as="item" t-key="item_index">
<div class="rank-row" t-on-click="onChartClick"
t-att-data-section-id="item.sectionId"
t-att-data-card-label="item.cardLabel"
t-att-title="item.label" style="cursor: pointer;">
<div class="rank-label" t-esc="item.label"/>
<div class="rank-track">
<div class="rank-bar" t-att-style="'width: ' + item.widthPct + '%; background: ' + item.color + ';'"/>
</div>
<div class="rank-value" t-esc="formatValue(item.rawValue)"/>
</div>
</t>
</div>
</div>
<!-- 关键指标卡 -->
<div class="dashboard-chart-wrapper" t-if="state.chartSummary.length > 0">
<div class="chart-title"><i class="fa fa-tachometer" style="margin-right: 6px; color: #1890ff;"></i>关键指标</div>
<div class="summary-grid">
<t t-foreach="state.chartSummary" t-as="m" t-key="m_index">
<div class="summary-card" t-att-style="'border-left-color: ' + m.color + ';'">
<div class="summary-label" t-esc="m.label"/>
<div class="summary-value" t-att-style="'color: ' + m.color + ';'" t-esc="formatValue(m.value)"/>
</div>
</t>
</div>
</div>
</div>
<!-- 环形图 + 各板块明细柱状图:同一网格自动并排,屏幕宽时环左边、右边可放两个柱状图 -->
<div class="chart-grid chart-grid-bars" t-if="state.chartPieSlices.length > 0 or state.chartSections.length > 0">
<div class="dashboard-chart-wrapper pie-chart-card" t-if="state.chartPieSlices.length > 0">
<div class="chart-title"><i class="fa fa-pie-chart" style="margin-right: 6px; color: #1890ff;"></i>经营数据构成</div>
<div class="pie-chart-container">
<svg viewBox="0 0 200 200" class="donut-svg" xmlns="http://www.w3.org/2000/svg">
<g>
<t t-foreach="state.chartPieSlices" t-as="slice" t-key="slice_index">
<path t-att-d="slice.path"
t-att-fill="slice.color"
t-on-click="onChartClick"
t-att-data-section-id="slice.sectionId"
t-att-data-card-label="slice.label"
style="cursor: pointer;"/>
</t>
</g>
<!-- 中心圆挖空成环形 -->
<circle cx="100" cy="100" r="55" fill="#fff"/>
<text x="100" y="96" text-anchor="middle" class="donut-center-label" t-esc="'构成'"/>
<text x="100" y="116" text-anchor="middle" class="donut-center-value" t-esc="state.chartPieSlices.length + ' 项'"/>
</svg>
<div class="pie-legend">
<t t-foreach="state.chartPieSlices" t-as="slice" t-key="slice_index">
<div class="legend-item"
t-on-click="onChartClick"
t-att-data-section-id="slice.sectionId"
t-att-data-card-label="slice.label"
style="cursor: pointer;">
<span class="legend-color" t-att-style="'background: ' + slice.color + ';'"/>
<span class="legend-label" t-esc="slice.label"/>
<span class="legend-value" t-esc="formatValue(slice.rawValue)"/>
<span class="legend-percent" t-esc="slice.percent + '%'"/>
</div>
</t>
</div>
</div>
</div>
<t t-foreach="state.chartSections" t-as="sec" t-key="sec.id">
<div class="dashboard-chart-wrapper">
<div class="chart-title" t-esc="sec.title"/>
<div class="bar-chart">
<t t-foreach="sec.bars" t-as="bar" t-key="bar_index">
<div class="bar-item" t-on-click="onChartClick"
t-att-data-section-id="sec.id"
t-att-data-card-label="bar.label"
t-att-title="bar.label" style="cursor: pointer;">
<div class="bar-value" t-esc="formatValue(bar.rawValue)"/>
<div class="bar-track">
<div class="bar-rect" t-att-style="'height: ' + bar.heightPct + '%; background: ' + bar.color + ';'"/>
</div>
<div class="bar-label" t-esc="bar.label"/>
</div>
</t>
</div>
</div>
</t>
</div>
</div>
<!-- 卡片视图:原有逻辑保持不变 -->
<t t-foreach="state.dashboardData.sections" t-as="section" t-key="section.id">
<!-- 板块标题 -->
@@ -92,7 +200,7 @@
<t t-foreach="section.row_groups" t-as="rowGroup" t-key="rowGroup_index">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<t t-foreach="rowGroup" t-as="cardLabel" t-key="cardLabel">
<t t-foreach="rowGroup" t-as="cardLabel" t-key="cardLabel_index">
<t t-set="card" t-value="section.cards.find(c => c.label === cardLabel)"/>
<div t-if="card" class="dashboard-card" t-on-click="onCardClick"
@@ -168,6 +276,7 @@
</t>
</div>
<!-- ========== 加载提示 ========== -->