new
This commit is contained in:
@@ -10,7 +10,19 @@
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'project_md/static/lib/d3.v7.min.js',
|
||||
'project_md/static/lib/d3/d3.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',
|
||||
],
|
||||
# Omux 重定义了 web.assets_web_dark 且不继承 web.assets_backend,
|
||||
# mindmap 视图类型必须在此 bundle 也注册,否则夜色模式下点击导图报错。
|
||||
# 此为功能注册(非主题美化),不可删除。
|
||||
'web.assets_web_dark': [
|
||||
'project_md/static/lib/d3/d3.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',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,3 +2,4 @@ from . import mindmap_diagram
|
||||
from . import mindmap_node
|
||||
from . import project_task_extend
|
||||
from . import ir_ui_view_extend
|
||||
from . import project_mindmap_extend
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,3 +9,12 @@ class IrUiView(models.Model):
|
||||
selection_add=[('mindmap', 'Mindmap')],
|
||||
string='View Type',
|
||||
)
|
||||
|
||||
def _get_view_info(self):
|
||||
# 关键:只 selection_add 不够。get_view_info 只会把
|
||||
# “既在选择项里、又在 _get_view_info() 字典里”的类型塞进
|
||||
# session.view_info。若不在此登记 mindmap 的元数据,前端会报
|
||||
# “View types not defined mindmap”。
|
||||
res = super()._get_view_info()
|
||||
res['mindmap'] = {'icon': 'fa fa-sitemap', 'multi_record': True}
|
||||
return res
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class ProjectProject(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
@api.model
|
||||
def get_mindmap_tree(self, single_id=None):
|
||||
"""返回 项目→阶段→任务 的树形结构(dict),供 mindmap 视图渲染。
|
||||
|
||||
不建中间表:直接利用已有的
|
||||
project.project.type_ids(阶段)/ project.task.project_id / project.task.stage_id。
|
||||
字段是否存在在服务端用 _fields 直接判断,杜绝 JS 侧写死字段导致的
|
||||
Invalid field / 白屏。
|
||||
"""
|
||||
self = self.sudo()
|
||||
|
||||
# 1) 项目(单项目视角则只取当前项目)
|
||||
proj_domain = [('id', '=', single_id)] if single_id else []
|
||||
projects = self.search_read(proj_domain, ['name', 'type_ids'])
|
||||
|
||||
# 2) 阶段:经各项目 type_ids 收集、保序去重
|
||||
stage_ids = []
|
||||
for p in projects:
|
||||
stage_ids += p['type_ids']
|
||||
stage_ids = list(dict.fromkeys(stage_ids))
|
||||
stages = self.env['project.task.type'].search_read(
|
||||
[('id', 'in', stage_ids)], ['name', 'sequence']
|
||||
) if stage_ids else []
|
||||
stage_by_id = {s['id']: s for s in stages}
|
||||
|
||||
# 3) 任务:进度字段在部分版本/客制下不存在,服务端安全探测
|
||||
task_fields = ['name', 'display_name', 'project_id', 'stage_id']
|
||||
tk = self.env['project.task']
|
||||
progress_field = None
|
||||
if 'subtask_completion_percentage' in tk._fields:
|
||||
progress_field = 'subtask_completion_percentage'
|
||||
elif 'progress' in tk._fields:
|
||||
progress_field = 'progress'
|
||||
if progress_field:
|
||||
task_fields.append(progress_field)
|
||||
task_domain = [('project_id', '=', single_id)] if single_id else []
|
||||
tasks = tk.search_read(task_domain, task_fields)
|
||||
|
||||
def _norm(v):
|
||||
if v is None or v is False:
|
||||
return 0
|
||||
try:
|
||||
v = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
# 部分字段存 0-1,有的存 0-100;统一归一到 0-100
|
||||
return int(v) if v > 1 else int(v * 100)
|
||||
|
||||
project_nodes = {}
|
||||
for p in projects:
|
||||
project_nodes[p['id']] = {
|
||||
'id': 'P%d' % p['id'],
|
||||
'name': p['name'] or ('项目%s' % p['id']),
|
||||
'kind': 'project',
|
||||
'progress': 0,
|
||||
'children': [],
|
||||
}
|
||||
|
||||
for p in projects:
|
||||
pn = project_nodes[p['id']]
|
||||
proj_stages = [stage_by_id[i] for i in p['type_ids'] if i in stage_by_id]
|
||||
proj_stages.sort(key=lambda s: s.get('sequence') or 0)
|
||||
for st in proj_stages:
|
||||
sn = {
|
||||
'id': 'P%d_S%d' % (p['id'], st['id']),
|
||||
'name': st['name'],
|
||||
'kind': 'stage',
|
||||
'progress': 0,
|
||||
'children': [],
|
||||
}
|
||||
pn['children'].append(sn)
|
||||
st_tasks = [
|
||||
t for t in tasks
|
||||
if t.get('project_id') and t['project_id'][0] == p['id']
|
||||
and t.get('stage_id') and t['stage_id'][0] == st['id']
|
||||
]
|
||||
for t in st_tasks:
|
||||
raw = t.get(progress_field) if progress_field else None
|
||||
sn['children'].append({
|
||||
'id': 'T%d' % t['id'],
|
||||
'name': t.get('display_name') or t['name'],
|
||||
'kind': 'task',
|
||||
'resId': t['id'],
|
||||
'progress': _norm(raw),
|
||||
})
|
||||
sn['progress'] = round(
|
||||
sum(c['progress'] for c in sn['children']) / len(sn['children'])
|
||||
) if sn['children'] else 0
|
||||
pn['progress'] = round(
|
||||
sum(c['progress'] for c in pn['children']) / len(pn['children'])
|
||||
) if pn['children'] else 0
|
||||
|
||||
roots = list(project_nodes.values())
|
||||
if single_id and len(roots) == 1:
|
||||
return roots[0]
|
||||
return {
|
||||
'id': 0,
|
||||
'name': '所有项目' if not single_id else '项目阶段与任务',
|
||||
'children': roots,
|
||||
}
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -16,12 +16,20 @@ export class MindmapController extends Component {
|
||||
}
|
||||
|
||||
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);
|
||||
// 取数 + 建树全部下沉到服务端 project.project.get_mindmap_tree:
|
||||
// 服务端用 _fields 安全探测字段存在性,从根上消灭 Invalid field;
|
||||
// 跨模型关联(project→type_ids→stage→task)也在 Python 侧一次性拼好。
|
||||
const singleProjectId =
|
||||
this.props.resModel === "project.project" && this.props.resId
|
||||
? this.props.resId
|
||||
: null;
|
||||
|
||||
const treeData = await this.orm.call(
|
||||
"project.project",
|
||||
"get_mindmap_tree",
|
||||
[singleProjectId || false]
|
||||
);
|
||||
this.state.treeData = treeData;
|
||||
this.state.loading = false;
|
||||
}
|
||||
|
||||
@@ -31,43 +39,25 @@ export class MindmapController extends Component {
|
||||
};
|
||||
}
|
||||
|
||||
_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) {
|
||||
const node = ev.detail.node;
|
||||
if (!node) return;
|
||||
let resModel;
|
||||
let resId;
|
||||
if (node.kind === "task") {
|
||||
resModel = "project.task";
|
||||
resId = node.resId;
|
||||
} else if (node.kind === "project") {
|
||||
resModel = "project.project";
|
||||
resId = Number(String(node.id).replace(/^P/, ""));
|
||||
} else {
|
||||
return; // 阶段节点是分组,不导航
|
||||
}
|
||||
if (resId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
res_model: resModel,
|
||||
res_id: recordId,
|
||||
res_id: resId,
|
||||
views: [[false, "form"]],
|
||||
target: "current",
|
||||
});
|
||||
|
||||
@@ -4,21 +4,18 @@ import { Component, onMounted, onWillUpdateProps } from "@odoo/owl";
|
||||
export class MindmapRenderer extends Component {
|
||||
setup() {
|
||||
super.setup();
|
||||
// 真正调用 owl 生命周期钩子,否则 _renderTree 永远不会执行(导图白屏)
|
||||
onMounted(() => this._renderTree());
|
||||
onWillUpdateProps(() => {
|
||||
// 等待 owl 完成 DOM patch 后再重绘 d3,避免直接操作 DOM 与 owl 冲突
|
||||
Promise.resolve().then(() => this._renderTree());
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -29,7 +26,7 @@ export class MindmapRenderer extends Component {
|
||||
if (!container || !this.treeData) return;
|
||||
|
||||
const width = Math.max(container.clientWidth, 900);
|
||||
const height = 560;
|
||||
const height = Math.max(container.clientHeight || 560, 560);
|
||||
|
||||
// 清空后重绘
|
||||
container.innerHTML = "";
|
||||
@@ -75,7 +72,6 @@ export class MindmapRenderer extends Component {
|
||||
.append("path")
|
||||
.attr("class", "mindmap-link")
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#90A4AE")
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("opacity", 0.6)
|
||||
.attr("d", (d) => {
|
||||
@@ -103,8 +99,18 @@ export class MindmapRenderer extends Component {
|
||||
.attr("height", 36)
|
||||
.attr("x", -80)
|
||||
.attr("y", -18)
|
||||
.attr("fill", (d) => (d.children ? "#1E88E5" : "#43A047"))
|
||||
.attr("stroke", (d) => (d.children ? "#1565C0" : "#2E7D32"))
|
||||
.attr("fill", (d) => {
|
||||
const k = d.data.kind;
|
||||
if (k === "project") return "#5E35B1";
|
||||
if (k === "stage") return "#1E88E5";
|
||||
return "#43A047";
|
||||
})
|
||||
.attr("stroke", (d) => {
|
||||
const k = d.data.kind;
|
||||
if (k === "project") return "#4527A0";
|
||||
if (k === "stage") return "#1565C0";
|
||||
return "#2E7D32";
|
||||
})
|
||||
.attr("stroke-width", 1.5)
|
||||
.style("filter", "drop-shadow(1px 2px 3px rgba(0,0,0,0.2))")
|
||||
.on("mouseenter", function () {
|
||||
@@ -135,9 +141,9 @@ export class MindmapRenderer extends Component {
|
||||
return name.length > 14 ? name.substring(0, 13) + "…" : name;
|
||||
});
|
||||
|
||||
// 进度环(有进度的叶子任务)
|
||||
// 进度环(任意带进度的节点:任务/阶段/项目 都显示"进行到哪一步")
|
||||
nodes
|
||||
.filter((d) => !d.children && d.data.progress && d.data.progress > 0)
|
||||
.filter((d) => d.data.progress && d.data.progress > 0)
|
||||
.append("circle")
|
||||
.attr("cx", 66)
|
||||
.attr("cy", -14)
|
||||
@@ -154,10 +160,11 @@ export class MindmapRenderer extends Component {
|
||||
.attr("stroke-dashoffset", circ * (1 - p));
|
||||
});
|
||||
|
||||
// 点击 → 冒泡事件给 Controller
|
||||
// 点击 → 把整个 node(含 kind/resId)冒泡给 Controller 决定导航
|
||||
// 注意事件名必须与模板 t-on-node-click-ed 一致,否则不触发
|
||||
nodes.on("click", (event, d) => {
|
||||
if (d.data.id) {
|
||||
this.trigger("node-clicked", { id: d.data.id });
|
||||
if (d.data && d.data.kind) {
|
||||
this.trigger("node-click-ed", { node: d.data });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MindmapRenderer } from "./mindmap_renderer";
|
||||
|
||||
export const mindmapView = {
|
||||
type: "mindmap",
|
||||
display_name: "Mindmap",
|
||||
display_name: "思维导图",
|
||||
icon: "fa fa-sitemap",
|
||||
multiRecord: true,
|
||||
ArchParser: MindmapArchParser,
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
.o_controller_mindmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mindmap-renderer-root {
|
||||
flex: 1 1 auto;
|
||||
min-height: 560px;
|
||||
overflow: auto;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 10px;
|
||||
@@ -25,6 +31,25 @@
|
||||
filter: drop-shadow(2px 4px 8px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
/* 连接线颜色交给 CSS 控制,便于夜色模式覆盖(d3 不再写死 stroke) */
|
||||
.mindmap-link {
|
||||
pointer-events: none;
|
||||
stroke: #90a4ae;
|
||||
}
|
||||
|
||||
.mindmap-node text {
|
||||
fill: #ffffff;
|
||||
}
|
||||
|
||||
/* 深色模式适配:深色主题下容器与连线都不要纯白/浅灰 */
|
||||
.o_web_client.o_dark_theme .mindmap-renderer-root,
|
||||
.o_web_client.dark .mindmap-renderer-root {
|
||||
background: linear-gradient(135deg, #1e2228 0%, #262a30 100%);
|
||||
border-color: #3a4048;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.o_web_client.o_dark_theme .mindmap-link,
|
||||
.o_web_client.dark .mindmap-link {
|
||||
stroke: #5b6470;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?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">
|
||||
<div class="o_view_controller o_controller_mindmap">
|
||||
<t t-if="state.treeData">
|
||||
<t t-component="props.Renderer"
|
||||
t-props="rendererProps"
|
||||
t-on-node-clicked="onNodeClicked"/>
|
||||
t-on-node-click-ed="onNodeClicked"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="o_mindmap_loading">加载中…</div>
|
||||
@@ -15,6 +15,6 @@
|
||||
|
||||
<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;"/>
|
||||
style="width:100%;position:relative;"/>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- 注册 mindmap 视图类型(后端需有一条该 type 的 view 记录) -->
|
||||
<!-- 任务级 mindmap 视图:保留,用于在单个项目的任务里切换 -->
|
||||
<record id="view_project_task_mindmap" model="ir.ui.view">
|
||||
<field name="name">project.task.mindmap</field>
|
||||
<field name="model">project.task</field>
|
||||
@@ -10,23 +10,26 @@
|
||||
</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>
|
||||
<!-- 项目级 mindmap 视图:用于"所有项目"动作里直接看所有项目 -->
|
||||
<record id="view_project_project_mindmap" model="ir.ui.view">
|
||||
<field name="name">project.project.mindmap</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="type">mindmap</field>
|
||||
<field name="arch" type="xml">
|
||||
<mindmap name_field="name"/>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- 挂到项目菜单下 -->
|
||||
<menuitem id="menu_project_task_mindmap"
|
||||
name="任务思维导图"
|
||||
parent="project.menu_project_management"
|
||||
action="action_project_task_mindmap"
|
||||
sequence="20"/>
|
||||
<!-- 注入到标准"所有项目"动作,使顶部视图切换栏出现思维导图图标 -->
|
||||
<record id="project.open_view_project_all" model="ir.actions.act_window">
|
||||
<field name="view_mode">kanban,list,form,mindmap</field>
|
||||
</record>
|
||||
|
||||
<!-- 注入到标准"所有任务"动作,使顶部视图切换栏出现思维导图图标 -->
|
||||
<record id="project.open_view_project_all_group_stage" model="ir.actions.act_window">
|
||||
<field name="view_mode">kanban,list,form,mindmap</field>
|
||||
</record>
|
||||
|
||||
<!-- 注入到标准"所有任务"动作,任务视图也能切换成导图 -->
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user