diff --git a/ks_dashboard_ninja/static/images/dashboardOverview/All Dashboards.svg b/ks_dashboard_ninja/static/images/dashboardOverview/全部仪表盘.svg similarity index 100% rename from ks_dashboard_ninja/static/images/dashboardOverview/All Dashboards.svg rename to ks_dashboard_ninja/static/images/dashboardOverview/全部仪表盘.svg diff --git a/ks_dashboard_ninja/static/images/dashboardOverview/All Lists.svg b/ks_dashboard_ninja/static/images/dashboardOverview/全部列表.svg similarity index 100% rename from ks_dashboard_ninja/static/images/dashboardOverview/All Lists.svg rename to ks_dashboard_ninja/static/images/dashboardOverview/全部列表.svg diff --git a/ks_dashboard_ninja/static/images/dashboardOverview/All Charts.svg b/ks_dashboard_ninja/static/images/dashboardOverview/全部图表.svg similarity index 100% rename from ks_dashboard_ninja/static/images/dashboardOverview/All Charts.svg rename to ks_dashboard_ninja/static/images/dashboardOverview/全部图表.svg diff --git a/ks_dashboard_ninja/static/images/dashboardOverview/Total Maps.svg b/ks_dashboard_ninja/static/images/dashboardOverview/总地图.svg similarity index 100% rename from ks_dashboard_ninja/static/images/dashboardOverview/Total Maps.svg rename to ks_dashboard_ninja/static/images/dashboardOverview/总地图.svg diff --git a/ks_dashboard_ninja/static/images/dashboardOverview/Bookmarked Dashboards.svg b/ks_dashboard_ninja/static/images/dashboardOverview/收藏的仪表盘.svg similarity index 100% rename from ks_dashboard_ninja/static/images/dashboardOverview/Bookmarked Dashboards.svg rename to ks_dashboard_ninja/static/images/dashboardOverview/收藏的仪表盘.svg diff --git a/ks_dashboard_ninja/static/src/js/dashboards_overview.js b/ks_dashboard_ninja/static/src/js/dashboards_overview.js index d681015..4db27d6 100644 --- a/ks_dashboard_ninja/static/src/js/dashboards_overview.js +++ b/ks_dashboard_ninja/static/src/js/dashboards_overview.js @@ -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'); diff --git a/ks_dashboard_ninja/static/src/xml/ks_dashboard_ninja_templates.xml b/ks_dashboard_ninja/static/src/xml/ks_dashboard_ninja_templates.xml index 576e5d4..484b354 100644 --- a/ks_dashboard_ninja/static/src/xml/ks_dashboard_ninja_templates.xml +++ b/ks_dashboard_ninja/static/src/xml/ks_dashboard_ninja_templates.xml @@ -600,7 +600,7 @@
diff --git a/project_md/__init__.py b/project_md/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/project_md/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_md/__manifest__.py b/project_md/__manifest__.py new file mode 100644 index 0000000..7c5ac57 --- /dev/null +++ b/project_md/__manifest__.py @@ -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, +} diff --git a/project_md/models/__init__.py b/project_md/models/__init__.py new file mode 100644 index 0000000..6a719ac --- /dev/null +++ b/project_md/models/__init__.py @@ -0,0 +1,4 @@ +from . import mindmap_diagram +from . import mindmap_node +from . import project_task_extend +from . import ir_ui_view_extend diff --git a/project_md/models/ir_ui_view_extend.py b/project_md/models/ir_ui_view_extend.py new file mode 100644 index 0000000..72e912d --- /dev/null +++ b/project_md/models/ir_ui_view_extend.py @@ -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', + ) diff --git a/project_md/models/mindmap_diagram.py b/project_md/models/mindmap_diagram.py new file mode 100644 index 0000000..81f7920 --- /dev/null +++ b/project_md/models/mindmap_diagram.py @@ -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='关联业务模型') diff --git a/project_md/models/mindmap_node.py b/project_md/models/mindmap_node.py new file mode 100644 index 0000000..cdb122c --- /dev/null +++ b/project_md/models/mindmap_node.py @@ -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='自定义标签') diff --git a/project_md/models/project_task_extend.py b/project_md/models/project_task_extend.py new file mode 100644 index 0000000..266c795 --- /dev/null +++ b/project_md/models/project_task_extend.py @@ -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, + } diff --git a/project_md/security/ir.model.access.csv b/project_md/security/ir.model.access.csv new file mode 100644 index 0000000..5961cc9 --- /dev/null +++ b/project_md/security/ir.model.access.csv @@ -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 diff --git a/project_md/static/lib/d3.v7.min.js b/project_md/static/lib/d3.v7.min.js new file mode 100644 index 0000000..8d56002 --- /dev/null +++ b/project_md/static/lib/d3.v7.min.js @@ -0,0 +1,2 @@ +// https://d3js.org v7.8.5 Copyright 2010-2023 Mike Bostock +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).d3=t.d3||{})}(this,(function(t){"use strict";function n(t,n){return null==t||null==n?NaN:t0?d[i-1]:l,v.x1=i
0)for(i=0;i=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e=i)&&(e=i)}return e}function tt(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e =0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=un);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o >1)+h+t+M+S.slice(A);break;default:t=S+h+t+M}return u(t)}return y=void 0===y?6:/[gprs]/.test(_)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y)),M.toString=function(){return t+""},M}return{format:l,formatPrefix:function(t,n){var e=l(((t=Jc(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3))),i=Math.pow(10,-r),o=uf[8+r/3];return function(t){return e(i*t)+o}}}}function ff(n){return of=cf(n),t.format=of.format,t.formatPrefix=of.formatPrefix,of}function sf(t){return Math.max(0,-Zc(Math.abs(t)))}function lf(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3)))-Zc(Math.abs(t)))}function hf(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,Zc(n)-Zc(t))+1}t.format=void 0,t.formatPrefix=void 0,ff({thousands:",",grouping:[3],currency:["$",""]});var df=1e-6,pf=1e-12,gf=Math.PI,yf=gf/2,vf=gf/4,_f=2*gf,bf=180/gf,mf=gf/180,xf=Math.abs,wf=Math.atan,Mf=Math.atan2,Tf=Math.cos,Af=Math.ceil,Sf=Math.exp,Ef=Math.hypot,Nf=Math.log,kf=Math.pow,Cf=Math.sin,Pf=Math.sign||function(t){return t>0?1:t<0?-1:0},zf=Math.sqrt,$f=Math.tan;function Df(t){return t>1?0:t<-1?gf:Math.acos(t)}function Rf(t){return t>1?yf:t<-1?-yf:Math.asin(t)}function Ff(t){return(t=Cf(t/2))*t}function qf(){}function Uf(t,n){t&&Of.hasOwnProperty(t.type)&&Of[t.type](t,n)}var If={Feature:function(t,n){Uf(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r=0?1:-1,i=r*e,o=Tf(n=(n*=mf)/2+vf),a=Cf(n),u=Vf*a,c=Gf*o+u*Tf(i),f=u*r*Cf(i);as.add(Mf(f,c)),Xf=t,Gf=o,Vf=a}function ds(t){return[Mf(t[1],t[0]),Rf(t[2])]}function ps(t){var n=t[0],e=t[1],r=Tf(e);return[r*Tf(n),r*Cf(n),Cf(e)]}function gs(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function ys(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function vs(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function _s(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function bs(t){var n=zf(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}var ms,xs,ws,Ms,Ts,As,Ss,Es,Ns,ks,Cs,Ps,zs,$s,Ds,Rs,Fs={point:qs,lineStart:Is,lineEnd:Os,polygonStart:function(){Fs.point=Bs,Fs.lineStart=Ys,Fs.lineEnd=Ls,rs=new T,cs.polygonStart()},polygonEnd:function(){cs.polygonEnd(),Fs.point=qs,Fs.lineStart=Is,Fs.lineEnd=Os,as<0?(Wf=-(Kf=180),Zf=-(Qf=90)):rs>df?Qf=90:rs<-df&&(Zf=-90),os[0]=Wf,os[1]=Kf},sphere:function(){Wf=-(Kf=180),Zf=-(Qf=90)}};function qs(t,n){is.push(os=[Wf=t,Kf=t]),n t.r&&(t.r=t[n].r)}function c(){if(n){var r,i,o=n.length;for(e=new Array(o),r=0;r >>1;f[g]1;)i-=2;for(let t=2;t0){if(n>=this.ymax)return null;(i=(this.ymax-n)/r)0){if(t>=this.xmax)return null;(i=(this.xmax-t)/e)this.xmax?2:0)|(n9999?"+"+Ku(n,6):Ku(n,4))+"-"+Ku(t.getUTCMonth()+1,2)+"-"+Ku(t.getUTCDate(),2)+(o?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"."+Ku(o,3)+"Z":i?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"Z":r||e?"T"+Ku(e,2)+":"+Ku(r,2)+"Z":"")}function Ju(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return Hu;if(f)return f=!1,ju;var n,r,i=a;if(t.charCodeAt(i)===Xu){for(;a++=v)<<1|t>=y)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-f],p[p.length-1-f]=c)}else{var _=t-+this._x.call(null,g.data),b=n-+this._y.call(null,g.data),m=_*_+b*b;if(m=u)){(t.data!==n||t.next)&&(0===l&&(p+=(l=Uc(e))*l),0===h&&(p+=(h=Uc(e))*h),p(t=(Lc*t+jc)%Hc)/Hc}();function l(){h(),f.call("tick",n),e1?(f.on(t,e),n):f.on(t)}}},t.forceX=function(t){var n,e,r,i=qc(.1);function o(t){for(var i,o=0,a=n.length;o=.12&&i<.234&&r>=-.425&&r<-.214?u:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:a).invert(t)},s.stream=function(e){return t&&n===e?t:(r=[a.stream(n=e),u.stream(e),c.stream(e)],i=r.length,t={point:function(t,n){for(var e=-1;++ejs(r[0],r[1])&&(r[1]=i[1]),js(i[0],r[1])>js(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,n=0,r=o[e=o.length-1];n<=e;r=i,++n)i=o[n],(u=js(r[1],i[0]))>a&&(a=u,Wf=i[0],Kf=r[1])}return is=os=null,Wf===1/0||Zf===1/0?[[NaN,NaN],[NaN,NaN]]:[[Wf,Zf],[Kf,Qf]]},t.geoCentroid=function(t){ms=xs=ws=Ms=Ts=As=Ss=Es=0,Ns=new T,ks=new T,Cs=new T,Lf(t,Gs);var n=+Ns,e=+ks,r=+Cs,i=Ef(n,e,r);return i {
+ 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;
+ }
+}
diff --git a/project_md/static/src/js/mindmap_controller.js b/project_md/static/src/js/mindmap_controller.js
new file mode 100644
index 0000000..c75bc52
--- /dev/null
+++ b/project_md/static/src/js/mindmap_controller.js
@@ -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";
diff --git a/project_md/static/src/js/mindmap_renderer.js b/project_md/static/src/js/mindmap_renderer.js
new file mode 100644
index 0000000..26ab7f9
--- /dev/null
+++ b/project_md/static/src/js/mindmap_renderer.js
@@ -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";
diff --git a/project_md/static/src/js/mindmap_view.js b/project_md/static/src/js/mindmap_view.js
new file mode 100644
index 0000000..ec349dc
--- /dev/null
+++ b/project_md/static/src/js/mindmap_view.js
@@ -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);
diff --git a/project_md/static/src/scss/mindmap.scss b/project_md/static/src/scss/mindmap.scss
new file mode 100644
index 0000000..d0ce2b6
--- /dev/null
+++ b/project_md/static/src/scss/mindmap.scss
@@ -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;
+}
diff --git a/project_md/static/src/xml/mindmap_template.xml b/project_md/static/src/xml/mindmap_template.xml
new file mode 100644
index 0000000..a3018fc
--- /dev/null
+++ b/project_md/static/src/xml/mindmap_template.xml
@@ -0,0 +1,20 @@
+
+