From 8546dc4572c36c346dc1bf75b3151c751346a80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= Date: Thu, 20 Aug 2026 18:55:33 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=BE=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuthon_dashboard/yuthon_dashboard.js | 194 ++++++++++- .../yuthon_dashboard/yuthon_dashboard.scss | 307 ++++++++++++++++++ .../yuthon_dashboard/yuthon_dashboard.xml | 113 ++++++- 3 files changed, 609 insertions(+), 5 deletions(-) diff --git a/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.js b/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.js index 2cf9991ed..5989c3dcc 100644 --- a/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.js +++ b/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.js @@ -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; diff --git a/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.scss b/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.scss index 510bae47a..202cc89ad 100644 --- a/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.scss +++ b/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.scss @@ -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; diff --git a/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.xml b/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.xml index 307b34a0a..2523d21b7 100644 --- a/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.xml +++ b/yuthon_report/static/src/components/yuthon_dashboard/yuthon_dashboard.xml @@ -36,7 +36,7 @@ @@ -72,6 +72,114 @@
+ +
+ + +
+ 数据图表 + 点击任意图表元素可跳转对应报表 +
+ + +
+ + +
+
金额排行 TOP
+
+ +
+
+
+
+
+
+
+ +
+
+ + +
+
关键指标
+
+ +
+
+
+
+ +
+
+ +
+ + +
+
+
经营数据构成
+
+ + + + + + + + + + + +
+ +
+ + + + +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+ +
+ + @@ -92,7 +200,7 @@
- +
+