@@ -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>
|
||||
|
||||
<!-- ========== 加载提示 ========== -->
|
||||
|
||||
Reference in New Issue
Block a user