思维导图

This commit is contained in:
李鹏宇 2026-07-16 13:37:10 +08:00
parent cd653ce24c
commit c7ad84f993
23 changed files with 497 additions and 5 deletions

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 962 B

After

Width:  |  Height:  |  Size: 962 B

View File

@ -18,11 +18,11 @@ export class KSDashboardNinjaOverview extends Component{
this.actionService = useService("action");
this.notification = useService("notification");
this.dialogService = useService("dialog");
this.overviewTilesNames = [['All Dashboards', 'one'], ['All Charts', 'two'], ['Total Maps', 'three'],
['Bookmarked Dashboards', 'four'], ['All Lists', 'five']]
this.overviewTilesNames = [['全部仪表盘', 'one'], ['全部图表', 'two'], ['总地图', 'three'],
['收藏的仪表盘', 'four'], ['全部列表', 'five']]
this.state = useState({
bookmarkedDashboards: false,
filter: localStorage.getItem('dashboardFilter') || "All Dashboards",
filter: localStorage.getItem('dashboardFilter') || "全部仪表盘",
})
onWillStart(async () => {
@ -96,7 +96,7 @@ export class KSDashboardNinjaOverview extends Component{
let dashboardId = parseInt(ev.currentTarget.dataset.dashboardId);
let unBookmarkSvg = this.ks_overview.el.querySelector(`#unBookmark${dashboardId}`);
let bookmarkSvg = this.ks_overview.el.querySelector(`#bookmark${dashboardId}`);
let bookmarkCountTag = document.getElementById('Bookmarked Dashboards');
let bookmarkCountTag = document.getElementById('收藏的仪表盘');
if (unBookmarkSvg && bookmarkSvg){
unBookmarkSvg.classList.toggle('d-none');
bookmarkSvg.classList.toggle('d-none');

View File

@ -600,7 +600,7 @@
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" t-on-click="onFilterChange">All Dashboards</a></li>
<li><a class="dropdown-item" href="#" t-on-click="onFilterChange">全部仪表盘</a></li>
<li><a class="dropdown-item" href="#" t-on-click="onFilterChange">Bookmarked</a></li>
</ul>
</div>

1
project_md/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

View File

@ -0,0 +1,24 @@
{
'name': 'Project Mindmap (project_md)',
'summary': '项目管理思维导图视图D3 树形渲染)',
'version': '1.0',
'category': 'Project',
'depends': ['project', 'web'],
'data': [
'security/ir.model.access.csv',
'views/project_md_views.xml',
],
'assets': {
'web.assets_backend': [
'project_md/static/lib/d3.v7.min.js',
'project_md/static/src/js/mindmap_arch_parser.js',
'project_md/static/src/js/mindmap_controller.js',
'project_md/static/src/js/mindmap_renderer.js',
'project_md/static/src/js/mindmap_view.js',
'project_md/static/src/xml/mindmap_template.xml',
'project_md/static/src/scss/mindmap.scss',
],
},
'installable': True,
'application': False,
}

View File

@ -0,0 +1,4 @@
from . import mindmap_diagram
from . import mindmap_node
from . import project_task_extend
from . import ir_ui_view_extend

View File

@ -0,0 +1,11 @@
from odoo import fields, models
class IrUiView(models.Model):
_inherit = 'ir.ui.view'
# 注册自定义视图类型 mindmap使其能被 ir.ui.view 的 type 字段接受
type = fields.Selection(
selection_add=[('mindmap', 'Mindmap')],
string='View Type',
)

View File

@ -0,0 +1,9 @@
from odoo import models, fields
class MindmapDiagram(models.Model):
_name = 'mindmap.diagram'
_description = 'Mindmap Diagram'
name = fields.Char(string='名称', required=True)
res_model = fields.Char(string='关联业务模型')

View File

@ -0,0 +1,15 @@
from odoo import models, fields
class MindmapNode(models.Model):
_name = 'mindmap.node'
_description = 'Mindmap Node'
diagram_id = fields.Many2one('mindmap.diagram', string='导图', ondelete='cascade')
res_model = fields.Char(string='业务模型')
res_id = fields.Integer(string='业务记录ID')
parent_node_id = fields.Many2one('mindmap.node', string='父节点', ondelete='cascade')
x_pos = fields.Float(string='X坐标')
y_pos = fields.Float(string='Y坐标')
collapsed = fields.Boolean(string='已折叠', default=False)
custom_label = fields.Char(string='自定义标签')

View File

@ -0,0 +1,44 @@
from odoo import models
class ProjectTask(models.Model):
_inherit = 'project.task'
def get_hierarchy(self, project_id=None):
"""返回项目下任务的树形结构(嵌套 children供前端 D3 渲染。
:param project_id: 项目ID为空则返回所有任务
:return: 含虚拟根的嵌套字典
"""
domain = []
if project_id:
domain.append(('project_id', '=', project_id))
tasks = self.search(domain)
by_id = {}
for t in tasks:
by_id[t.id] = {
'id': t.id,
'name': t.name or '未命名',
'parent_id': t.parent_id.id if t.parent_id else None,
'progress': round(t.progress or 0),
'user_id': t.user_id.name if t.user_id else False,
'stage_id': t.stage_id.name if t.stage_id else False,
'date_deadline': t.date_deadline.strftime('%Y-%m-%d') if t.date_deadline else False,
'children': [],
}
roots = []
for t in tasks:
node = by_id[t.id]
pid = node['parent_id']
if pid and pid in by_id:
by_id[pid]['children'].append(node)
else:
roots.append(node)
return {
'id': 0,
'name': '项目任务',
'children': roots,
}

View File

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mindmap_diagram,mindmap.diagram,model_mindmap_diagram,base.group_user,1,1,1,1
access_mindmap_node,mindmap.node,model_mindmap_node,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mindmap_diagram mindmap.diagram model_mindmap_diagram base.group_user 1 1 1 1
3 access_mindmap_node mindmap.node model_mindmap_node base.group_user 1 1 1 1

2
project_md/static/lib/d3.v7.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,24 @@
/** @odoo-module **/
import { visitXML } from "@web/core/utils/xml";
export class MindmapArchParser {
parse(arch, fields = {}, resModel = "") {
const archInfo = {
fields,
resModel,
parentField: "parent_id",
nameField: "name",
};
visitXML(arch, (node) => {
if (node.tagName === "mindmap") {
if (node.hasAttribute("parent_field")) {
archInfo.parentField = node.getAttribute("parent_field");
}
if (node.hasAttribute("name_field")) {
archInfo.nameField = node.getAttribute("name_field");
}
}
});
return archInfo;
}
}

View File

@ -0,0 +1,77 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class MindmapController extends Component {
setup() {
super.setup();
this.orm = useService("orm");
this.action = useService("action");
this.notification = useService("notification");
this.state = useState({ treeData: null, loading: true });
onWillStart(async () => {
await this._loadData();
});
}
async _loadData() {
const resModel = this.props.archInfo.resModel;
const records = await this.orm.searchRead(resModel, [], [
"id", "name", "parent_id", "display_name",
"progress", "user_id", "stage_id", "date_deadline",
]);
this._buildTree(records);
this.state.loading = false;
}
get rendererProps() {
return {
treeData: this.state.treeData,
};
}
_buildTree(records) {
const map = {};
const roots = [];
for (const rec of records) {
map[rec.id] = {
id: rec.id,
name: rec.display_name || rec.name,
parentId: rec.parent_id ? rec.parent_id[0] : false,
progress: rec.progress || 0,
user_id: rec.user_id ? rec.user_id[1] : "",
stage_id: rec.stage_id ? rec.stage_id[1] : "",
date_deadline: rec.date_deadline || "",
children: [],
};
}
for (const id in map) {
const node = map[id];
if (node.parentId && map[node.parentId]) {
map[node.parentId].children.push(node);
} else {
roots.push(node);
}
}
this.state.treeData =
roots.length === 1
? roots[0]
: { id: 0, name: "全部任务", children: roots };
}
onNodeClicked(ev) {
const resModel = this.props.archInfo.resModel;
const recordId = ev.detail.id;
if (recordId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: resModel,
res_id: recordId,
views: [[false, "form"]],
target: "current",
});
}
}
}
MindmapController.template = "project_md.MindmapView";

View File

@ -0,0 +1,165 @@
/** @odoo-module **/
import { Component, onMounted, onWillUpdateProps } from "@odoo/owl";
export class MindmapRenderer extends Component {
setup() {
super.setup();
}
get treeData() {
return this.props.treeData;
}
onMounted() {
this._renderTree();
}
onWillUpdateProps() {
// 等待 owl 完成 DOM patch 后再重绘 d3避免直接操作 DOM 与 owl 冲突
Promise.resolve().then(() => this._renderTree());
}
_renderTree() {
const d3 = window.d3;
if (!d3) {
console.error("[Mindmap] D3.js not loaded");
return;
}
const container = this.el?.querySelector?.(".mindmap-renderer-root");
if (!container || !this.treeData) return;
const width = Math.max(container.clientWidth, 900);
const height = 560;
// 清空后重绘
container.innerHTML = "";
const svg = d3
.select(container)
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("class", "mindmap-svg");
const g = svg.append("g").attr("class", "mindmap-zoom-group");
const zoom = d3
.zoom()
.scaleExtent([0.15, 4])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
const root = d3.hierarchy(this.treeData);
root.descendants().forEach((d) => {
d.dataId = d.data.id;
});
const treeLayout = d3.tree().nodeSize([50, 220]);
treeLayout(root);
// 竖直居中
const x0 = d3.min(root.descendants(), (d) => d.x) || 0;
const x1 = d3.max(root.descendants(), (d) => d.x) || 0;
const initialTransform = d3.zoomIdentity.translate(
80,
(height - (x1 + x0)) / 2
);
svg.call(zoom.transform, initialTransform);
// 连接线
g.selectAll(".mindmap-link")
.data(root.links())
.enter()
.append("path")
.attr("class", "mindmap-link")
.attr("fill", "none")
.attr("stroke", "#90A4AE")
.attr("stroke-width", 1.5)
.attr("opacity", 0.6)
.attr("d", (d) => {
return `M${d.source.y},${d.source.x}
C${(d.source.y + d.target.y) / 2},${d.source.x}
${(d.source.y + d.target.y) / 2},${d.target.x}
${d.target.y},${d.target.x}`;
});
// 节点
const nodes = g
.selectAll(".mindmap-node")
.data(root.descendants())
.enter()
.append("g")
.attr("class", "mindmap-node")
.attr("transform", (d) => `translate(${d.y},${d.x})`)
.style("cursor", "pointer");
nodes
.append("rect")
.attr("rx", 6)
.attr("ry", 6)
.attr("width", 160)
.attr("height", 36)
.attr("x", -80)
.attr("y", -18)
.attr("fill", (d) => (d.children ? "#1E88E5" : "#43A047"))
.attr("stroke", (d) => (d.children ? "#1565C0" : "#2E7D32"))
.attr("stroke-width", 1.5)
.style("filter", "drop-shadow(1px 2px 3px rgba(0,0,0,0.2))")
.on("mouseenter", function () {
d3.select(this)
.transition()
.duration(150)
.attr("stroke-width", 2.5)
.style("filter", "drop-shadow(2px 4px 6px rgba(0,0,0,0.35))");
})
.on("mouseleave", function () {
d3.select(this)
.transition()
.duration(150)
.attr("stroke-width", 1.5)
.style("filter", "drop-shadow(1px 2px 3px rgba(0,0,0,0.2))");
});
nodes
.append("text")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("fill", "#fff")
.style("font-size", "13px")
.style("font-weight", "500")
.style("pointer-events", "none")
.text((d) => {
const name = d.data.name || "";
return name.length > 14 ? name.substring(0, 13) + "…" : name;
});
// 进度环(有进度的叶子任务)
nodes
.filter((d) => !d.children && d.data.progress && d.data.progress > 0)
.append("circle")
.attr("cx", 66)
.attr("cy", -14)
.attr("r", 10)
.attr("fill", "none")
.attr("stroke", "#FF9800")
.attr("stroke-width", 2.5)
.each(function (d) {
const r = 10;
const circ = 2 * Math.PI * r;
const p = d.data.progress / 100;
d3.select(this)
.attr("stroke-dasharray", circ)
.attr("stroke-dashoffset", circ * (1 - p));
});
// 点击 → 冒泡事件给 Controller
nodes.on("click", (event, d) => {
if (d.data.id) {
this.trigger("node-clicked", { id: d.data.id });
}
});
}
}
MindmapRenderer.template = "project_md.MindmapRenderer";

View File

@ -0,0 +1,30 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { MindmapArchParser } from "./mindmap_arch_parser";
import { MindmapController } from "./mindmap_controller";
import { MindmapRenderer } from "./mindmap_renderer";
export const mindmapView = {
type: "mindmap",
display_name: "Mindmap",
icon: "fa fa-sitemap",
multiRecord: true,
ArchParser: MindmapArchParser,
Controller: MindmapController,
Model: RelationalModel,
Renderer: MindmapRenderer,
props: (genericProps, view) => {
const { arch, fields, resModel } = genericProps;
const archInfo = new view.ArchParser().parse(arch, fields, resModel);
return {
...genericProps,
Model: view.Model,
Renderer: view.Renderer,
archInfo,
};
},
};
registry.category("views").add("mindmap", mindmapView);

View File

@ -0,0 +1,30 @@
.o_controller_mindmap {
height: 100%;
padding: 12px;
box-sizing: border-box;
}
.mindmap-renderer-root {
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
border: 1px solid #d0d7de;
border-radius: 10px;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.06);
transition: background 0.3s ease, border-color 0.3s ease;
}
.mindmap-svg {
display: block;
margin: auto;
}
.mindmap-node rect {
transition: all 0.2s ease;
}
.mindmap-node:hover rect {
filter: drop-shadow(2px 4px 8px rgba(0, 0, 0, 0.3));
}
.mindmap-link {
pointer-events: none;
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="project_md.MindmapView" owl="1">
<div class="o_content o_controller_mindmap">
<t t-if="state.treeData">
<t t-component="props.Renderer"
t-props="rendererProps"
t-on-node-clicked="onNodeClicked"/>
</t>
<t t-else="">
<div class="o_mindmap_loading">加载中…</div>
</t>
</div>
</t>
<t t-name="project_md.MindmapRenderer" owl="1">
<div class="mindmap-renderer-root"
style="width:100%;height:560px;overflow:hidden;background:#f5f7fa;border-radius:8px;position:relative;"/>
</t>
</templates>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- 注册 mindmap 视图类型(后端需有一条该 type 的 view 记录) -->
<record id="view_project_task_mindmap" model="ir.ui.view">
<field name="name">project.task.mindmap</field>
<field name="model">project.task</field>
<field name="type">mindmap</field>
<field name="arch" type="xml">
<mindmap/>
</field>
</record>
<!-- 独立动作:打开任务思维导图(补全多视图模式,可从导图切回看板等) -->
<record id="action_project_task_mindmap" model="ir.actions.act_window">
<field name="name">任务思维导图</field>
<field name="res_model">project.task</field>
<field name="view_mode">kanban,list,form,calendar,pivot,graph,activity,mindmap</field>
<field name="view_id" ref="view_project_task_mindmap"/>
<field name="context">{'default_view_mode': 'mindmap'}</field>
</record>
<!-- 挂到项目菜单下 -->
<menuitem id="menu_project_task_mindmap"
name="任务思维导图"
parent="project.menu_project_management"
action="action_project_task_mindmap"
sequence="20"/>
<!-- 注入到标准"所有任务"动作,使顶部视图切换栏出现思维导图图标 -->
<record id="project.action_view_all_task" model="ir.actions.act_window">
<field name="view_mode">kanban,list,form,calendar,pivot,graph,activity,mindmap</field>
</record>
</odoo>