思维导图
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 962 B After Width: | Height: | Size: 962 B |
|
Before Width: | Height: | Size: 656 B After Width: | Height: | Size: 656 B |
@ -18,11 +18,11 @@ export class KSDashboardNinjaOverview extends Component{
|
|||||||
this.actionService = useService("action");
|
this.actionService = useService("action");
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
this.dialogService = useService("dialog");
|
this.dialogService = useService("dialog");
|
||||||
this.overviewTilesNames = [['All Dashboards', 'one'], ['All Charts', 'two'], ['Total Maps', 'three'],
|
this.overviewTilesNames = [['全部仪表盘', 'one'], ['全部图表', 'two'], ['总地图', 'three'],
|
||||||
['Bookmarked Dashboards', 'four'], ['All Lists', 'five']]
|
['收藏的仪表盘', 'four'], ['全部列表', 'five']]
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
bookmarkedDashboards: false,
|
bookmarkedDashboards: false,
|
||||||
filter: localStorage.getItem('dashboardFilter') || "All Dashboards",
|
filter: localStorage.getItem('dashboardFilter') || "全部仪表盘",
|
||||||
})
|
})
|
||||||
|
|
||||||
onWillStart(async () => {
|
onWillStart(async () => {
|
||||||
@ -96,7 +96,7 @@ export class KSDashboardNinjaOverview extends Component{
|
|||||||
let dashboardId = parseInt(ev.currentTarget.dataset.dashboardId);
|
let dashboardId = parseInt(ev.currentTarget.dataset.dashboardId);
|
||||||
let unBookmarkSvg = this.ks_overview.el.querySelector(`#unBookmark${dashboardId}`);
|
let unBookmarkSvg = this.ks_overview.el.querySelector(`#unBookmark${dashboardId}`);
|
||||||
let bookmarkSvg = this.ks_overview.el.querySelector(`#bookmark${dashboardId}`);
|
let bookmarkSvg = this.ks_overview.el.querySelector(`#bookmark${dashboardId}`);
|
||||||
let bookmarkCountTag = document.getElementById('Bookmarked Dashboards');
|
let bookmarkCountTag = document.getElementById('收藏的仪表盘');
|
||||||
if (unBookmarkSvg && bookmarkSvg){
|
if (unBookmarkSvg && bookmarkSvg){
|
||||||
unBookmarkSvg.classList.toggle('d-none');
|
unBookmarkSvg.classList.toggle('d-none');
|
||||||
bookmarkSvg.classList.toggle('d-none');
|
bookmarkSvg.classList.toggle('d-none');
|
||||||
|
|||||||
@ -600,7 +600,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="dropdown-menu">
|
<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>
|
<li><a class="dropdown-item" href="#" t-on-click="onFilterChange">Bookmarked</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1
project_md/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
24
project_md/__manifest__.py
Normal 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,
|
||||||
|
}
|
||||||
4
project_md/models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from . import mindmap_diagram
|
||||||
|
from . import mindmap_node
|
||||||
|
from . import project_task_extend
|
||||||
|
from . import ir_ui_view_extend
|
||||||
11
project_md/models/ir_ui_view_extend.py
Normal 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',
|
||||||
|
)
|
||||||
9
project_md/models/mindmap_diagram.py
Normal 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='关联业务模型')
|
||||||
15
project_md/models/mindmap_node.py
Normal 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='自定义标签')
|
||||||
44
project_md/models/project_task_extend.py
Normal 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,
|
||||||
|
}
|
||||||
3
project_md/security/ir.model.access.csv
Normal 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
|
||||||
|
2
project_md/static/lib/d3.v7.min.js
vendored
Normal file
24
project_md/static/src/js/mindmap_arch_parser.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
project_md/static/src/js/mindmap_controller.js
Normal 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";
|
||||||
165
project_md/static/src/js/mindmap_renderer.js
Normal 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";
|
||||||
30
project_md/static/src/js/mindmap_view.js
Normal 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);
|
||||||
30
project_md/static/src/scss/mindmap.scss
Normal 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;
|
||||||
|
}
|
||||||
20
project_md/static/src/xml/mindmap_template.xml
Normal 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>
|
||||||
33
project_md/views/project_md_views.xml
Normal 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>
|
||||||