本文旨在提供一个从架构设计、核心功能实现到高级用户体验优化的全面指南,详细阐述如何在Odoo 18中,完全利用其原生的Owl前端框架,为渐进式网络应用(PWA)从零开始开发一个功能完备、数据驱动且高度可定制的底部导航栏。我们将深入探讨组件注入、路由集成、后端实时通信、动态权限渲染、全局状态管理及PWA离线生命周期等关键技术领域。
第一部分:架构设计与核心组件构建
本部分将奠定整个项目的基础,阐述导航栏Owl组件的基础结构设计,包括如何定义组件的模板(XML)、样式(CSS)和逻辑(JS),并确立其在Odoo Web客户端整体布局中的位置和集成方式。成功的关键在于找到一个稳定且无侵入性的注入点,以确保导航栏作为持久性UI元素存在,独立于主内容区的视图切换。
1.1 模块结构与资产定义
首先,所有定制开发都应封装在一个独立的Odoo模块中,以保证代码的模块化和可维护性。
模块文件结构示例 (custom_pwa_navbar
):
custom_pwa_navbar/
├── __init__.py
├── __manifest__.py
├── static/
│ └── src/
│ ├── components/
│ │ └── pwa_navbar/
│ │ ├── pwa_navbar.js
│ │ ├── pwa_navbar.xml
│ │ └── pwa_navbar.scss
│ └── js/
│ └── navbar_patch.js
└── views/
└── assets.xml
__manifest__.py
文件配置:
清单文件是模块的入口点,必须在assets
字典中声明所有前端资源,以便Odoo的资产管理器能够正确打包和加载它们。
# custom_pwa_navbar/__manifest__.py
{
'name': 'PWA Bottom Navigation Bar',
'version': '18.0.1.0.0',
'category': 'Productivity',
'summary': 'Adds a persistent PWA-style bottom navigation bar to the Odoo 18 Web Client.',
'author': 'Your Name',
'website': 'https://your.website.com',
'license': 'LGPL-3',
'depends': ['web'],
'data': [],
'assets': {
'web.assets_backend': [
# 引入组件的SCSS样式
'custom_pwa_navbar/static/src/components/pwa_navbar/pwa_navbar.scss',
# 引入组件的JS逻辑和QWeb模板
'custom_pwa_navbar/static/src/components/pwa_navbar/pwa_navbar.js',
'custom_pwa_navbar/static/src/components/pwa_navbar/pwa_navbar.xml',
# 引入用于注入组件的Patch逻辑
'custom_pwa_navbar/static/src/js/navbar_patch.js',
],
},
'installable': True,
'application': False,
}
1.2 核心组件:PwaNavBar
PwaNavBar
是导航栏的核心Owl组件,负责渲染UI并处理用户交互。
pwa_navbar.js
(组件逻辑):
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
export class PwaNavBar extends Component {
static template = "custom_pwa_navbar.PwaNavBar";
setup() {
// 使用useState管理导航项的状态,例如激活项
this.state = useState({
activeItem: 'home',
navItems: [
{ id: 'home', icon: 'fa-home', label: '首页', action: 'home_action' },
{ id: 'messages', icon: 'fa-comments', label: '消息', action: 'message_action', badge: 0 },
{ id: 'tasks', icon: 'fa-tasks', label: '任务', action: 'task_action', badge: 0 },
{ id: 'profile', icon: 'fa-user', label: '我的', action: 'profile_action' },
]
});
}
onNavItemClick(itemId) {
this.state.activeItem = itemId;
// 后续部分将实现点击后触发客户端动作
console.log(`Navigating to: ${itemId}`);
}
}
pwa_navbar.xml
(QWeb模板):
模板定义了组件的HTML结构。遵循addon_name.ComponentName
的命名约定以避免冲突。
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="custom_pwa_navbar.PwaNavBar" owl="1">
<div class="o_pwa_navbar">
<t t-foreach="state.navItems" t-as="item" t-key="item.id">
<div t-att-class="{
'o_pwa_navbar_item': true,
'active': state.activeItem === item.id
}"
t-on-click="() => this.onNavItemClick(item.id)">
<i t-att-class="'fa ' + item.icon"/>
<span class="o_pwa_navbar_label" t-esc="item.label"/>
<t t-if="item.badge > 0">
<span class="o_pwa_navbar_badge" t-esc="item.badge"/>
</t>
</div>
</t>
</div>
</t>
</templates>
pwa_navbar.scss
(组件样式):
.o_pwa_navbar {
display: none; // 默认在桌面端隐藏
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 60px;
background-color: #ffffff;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: space-around;
align-items: center;
z-index: 1050; // 需要仔细管理z-index,确保在内容之上,但在模态框之下
box-shadow: 0 -2px 5px rgba(0,0,0,0.05);
.o_pwa_navbar_item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #757575;
cursor: pointer;
flex-grow: 1;
position: relative;
padding: 5px 0;
transition: color 0.2s ease-in-out;
&.active {
color: var(--o-brand-primary);
}
.fa {
font-size: 22px;
}
.o_pwa_navbar_label {
font-size: 12px;
margin-top: 4px;
}
.o_pwa_navbar_badge {
position: absolute;
top: 2px;
right: 20%;
background-color: red;
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 10px;
font-weight: bold;
line-height: 1;
}
}
}
// 使用媒体查询,仅在移动设备屏幕宽度下显示导航栏
@media (max-width: 767.98px) {
.o_pwa_navbar {
display: flex;
}
// 为主内容区增加padding,防止被导航栏遮挡
.o_action_manager {
padding-bottom: 60px;
}
}
1.3 注入点分析与patch
实现
这是整个架构中最关键且最具挑战性的一步。 目标是将PwaNavBar
组件作为一个持久性元素注入到Odoo的WebClient
中。
研究与分析:
根据研究,Odoo 18的WebClient
是整个后端UI的根Owl组件,其模板为webclient_templates.xml
。它被直接挂载到document.body
上,并包含了navbar
、action_container
等主要UI区域。为了实现持久化,我们不能将导航栏注入到action_container
内部,因为这会导致它随视图切换而被销毁和重建。
最佳注入策略:
最佳策略是修补(patch)WebClient
组件,在其DOM结构中找到一个合适的、持久的挂载点。
navbar_patch.js
(注入逻辑):
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { WebClient } from "@web/web_client/web_client";
import { PwaNavBar } from "@custom_pwa_navbar/components/pwa_navbar/pwa_navbar";
import { onMounted, onWillUnmount, useRef } from "@odoo/owl";
patch(WebClient.prototype, {
setup() {
super.setup(...arguments);
this.pwaNavBarRoot = useRef("pwaNavBarRoot");
let pwaNavBarComponent = null;
onMounted(async () => {
// **关键注入逻辑**
// 1. 创建一个新的DOM元素作为挂载点
const navBarElement = document.createElement('div');
navBarElement.setAttribute('class', 'o_pwa_navbar_container');
// 2. 将挂载点附加到WebClient的根元素(this.el)上
// this.el 指向WebClient渲染后的根DOM节点
// **预测与假设**: 将其作为WebClient根元素的直接子节点是最可靠的持久化方式。
// 这确保了它与action_manager同级,而不会被其内部的视图切换所影响。
if (this.el) {
this.el.appendChild(navBarElement);
// 3. 在新创建的挂载点上挂载我们的PwaNavBar组件
pwaNavBarComponent = new PwaNavBar(null, {}); // 第二个参数是props
await pwaNavBarComponent.mount(navBarElement);
}
});
onWillUnmount(() => {
// 在WebClient卸载时,确保我们的组件也被正确销毁
if (pwaNavBarComponent) {
pwaNavBarComponent.destroy();
}
});
}
});
架构决策与说明:
- 为什么选择
patch
?patch
是Odoo 18官方推荐的、用于非侵入式地修改核心组件行为的API。它比直接覆写整个组件或通过jQuery进行DOM操作更为健壮和可维护。 - 为什么选择
onMounted
? 我们在WebClient
的onMounted
钩子中执行注入。这是因为我们需要确保WebClient
的根元素(this.el
)已经被渲染并附加到实际的DOM中,这样我们才能安全地向其appendChild
。 - 为什么动态创建挂载点? 直接修改
webclient_templates.xml
是一种方法,但通过patch
在JS中动态创建和附加挂载点,可以减少对核心模板的依赖,降低未来Odoo版本升级时产生冲突的风险。这种方式更加灵活和解耦。 - 持久性保证: 由于我们的导航栏容器是
WebClient
根元素的直接子节点,并且与负责视图切换的action_manager
同级,因此它不会受到视图切换的影响,从而实现了UI的持久性。 - 响应式布局: 通过SCSS中的媒体查询,我们确保了导航栏仅在移动端视图下显示,并为主内容区添加了
padding-bottom
,避免了内容遮挡问题。这是实现良好移动端体验的关键。
通过以上设计,我们构建了一个结构清晰、可维护且与Odoo核心框架紧密集成的持久性导航栏组件,为后续的功能开发奠定了坚实的基础。
第二部分:路由与客户端动作集成
本部分将详细说明如何将导航栏按钮与Odoo的前端路由系统深度集成。目标是实现点击导航按钮后,能够精确地触发特定菜单(ir.ui.menu
)的加载、执行预定义的客户端动作(ir.actions.client
),并有效管理视图状态的切换。
2.1 Odoo前端路由与actionService
Odoo的单页应用(SPA)体验依赖于其内部的路由和动作服务。直接操作浏览器URL或History API是不可取且无效的。所有导航和视图切换都应通过actionService
来完成。
actionService
: 这是Odoo前端的核心服务之一,负责处理所有类型的动作(窗口动作、客户端动作、报表等)。通过useService("action")
钩子可以在Owl组件中访问它。doAction
方法: 这是actionService
的核心方法,用于执行一个动作。它可以接受一个动作的XML ID(字符串),或者一个描述动作的详细对象。
2.2 定义客户端动作 (ir.actions.client
)
为了将导航栏按钮与特定的Owl组件视图关联起来,我们需要定义ir.actions.client
记录。客户端动作允许我们将一个唯一的tag
与一个前端Owl组件关联起来。
示例:为“任务”页面定义客户端动作 (XML):
<!-- custom_pwa_navbar/views/actions.xml -->
<odoo>
<record id="action_client_pwa_tasks" model="ir.actions.client">
<field name="name">PWA Tasks</field>
<field name="tag">pwa_tasks_view</field>
<field name="target">current</field>
</record>
<!-- 同样可以定义其他页面的动作 -->
<record id="action_client_pwa_messages" model="ir.actions.client">
<field name="name">PWA Messages</field>
<field name="tag">pwa_messages_view</field>
</record>
</odoo>
tag
: 这是关键字段,它将作为前端JS代码中识别和注册组件的唯一标识符。target
: 控制动作的显示方式。current
表示在主内容区显示,new
表示在模态框中显示,fullscreen
则全屏显示。
2.3 注册客户端动作组件
定义了XML动作后,我们需要在JavaScript中创建一个对应的Owl组件,并使用actionRegistry
将其与tag
关联起来。
创建“任务”视图组件 (pwa_tasks_view.js
):
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { Component, onMounted, useState } from "@odoo/owl";
class PwaTasksView extends Component {
static template = "custom_pwa_navbar.PwaTasksView";
setup() {
this.state = useState({ tasks: [] });
onMounted(() => {
// 模拟获取任务数据
this.state.tasks = [
{ id: 1, name: "设计导航栏UI" },
{ id: 2, name: "实现路由集成" },
{ id: 3, name: "连接后端API" },
];
});
}
}
// 注册组件到动作注册表
registry.category("actions").add("pwa_tasks_view", PwaTasksView);
对应的QWeb模板 (pwa_tasks_view.xml
):
<templates xml:space="preserve">
<t t-name="custom_pwa_navbar.PwaTasksView" owl="1">
<div class="p-3">
<h1>我的任务</h1>
<ul class="list-group">
<t t-foreach="state.tasks" t-as="task" t-key="task.id">
<li class="list-group-item" t-esc="task.name"/>
</t>
</ul>
</div>
</t>
</templates>
确保这些新的JS和XML文件也加入到__manifest__.py
的web.assets_backend
中。
2.4 在导航栏中触发动作
现在,我们可以修改PwaNavBar
组件,使其在点击时调用actionService.doAction
。
修改后的 pwa_navbar.js
:
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class PwaNavBar extends Component {
static template = "custom_pwa_navbar.PwaNavBar";
setup() {
this.actionService = useService("action"); // 引入action服务
this.state = useState({
activeItem: 'home',
navItems: [
// 假设'home'对应一个已存在的菜单动作
{ id: 'home', icon: 'fa-home', label: '首页', action: 'web.action_web_client_home_menu', actionType: 'xml_id' },
// 'messages'和'tasks'对应我们自定义的客户端动作
{ id: 'messages', icon: 'fa-comments', label: '消息', action: { tag: 'pwa_messages_view', type: 'ir.actions.client' }, actionType: 'object' },
{ id: 'tasks', icon: 'fa-tasks', label: '任务', action: { tag: 'pwa_tasks_view', type: 'ir.actions.client' }, actionType: 'object' },
{ id: 'profile', icon: 'fa-user', label: '我的', action: { tag: 'pwa_profile_view', type: 'ir.actions.client' }, actionType: 'object' },
]
});
}
async onNavItemClick(item) {
if (this.state.activeItem === item.id) {
return; // 如果已经是激活项,则不执行任何操作
}
this.state.activeItem = item.id;
// **核心路由逻辑**
await this.actionService.doAction(item.action, {
// 清除面包屑,以获得更原生的PWA体验
clear_breadcrumbs: true,
});
}
}
逻辑解析:
useService("action")
: 我们使用此钩子获取actionService
的实例。navItems
结构更新:navItems
数组现在包含了执行动作所需的信息。我们可以混合使用动作的XML ID(对于Odoo原生动作)和动作描述对象(对于我们的自定义客户端动作)。doAction
调用: 在onNavItemClick
方法中,我们调用this.actionService.doAction(item.action)
。Odoo的actionService
会负责解释传入的字符串或对象,找到并执行对应的动作,最终渲染PwaTasksView
组件到主内容区。clear_breadcrumbs: true
: 这是一个非常有用的选项,它可以清除顶部的面包屑导航,使得PWA的界面更加简洁,更像一个独立应用。
2.5 传递参数与上下文
doAction
方法允许传递context
和domain
等参数,这对于创建动态和数据驱动的视图至关重要。
示例:打开“未读消息”视图
假设我们的pwa_messages_view
组件可以根据上下文显示所有消息或仅显示未读消息。
在PwaNavBar
中调用:
// 在PwaNavBar的某个方法中
this.actionService.doAction({
tag: 'pwa_messages_view',
type: 'ir.actions.client',
context: {
show_only_unread: true,
default_user_id: odoo.session_info.user_id,
}
});
在pwa_messages_view.js
中接收:
// ...
class PwaMessagesView extends Component {
static template = "custom_pwa_navbar.PwaMessagesView";
setup() {
// props.action.context 包含了传递过来的上下文
const context = this.props.action.context || {};
this.showOnlyUnread = context.show_only_unread || false;
console.log("Should show only unread messages:", this.showOnlyUnread);
// ...后续逻辑可以根据this.showOnlyUnread来获取不同数据
}
}
// ...
通过这种方式,导航栏不仅能切换视图,还能在切换时向目标视图传递初始状态或过滤条件,实现了强大的组件间通信和动态内容展示。
至此,我们已经成功地将底部导航栏与Odoo的客户端动作系统连接起来,实现了流畅的、受控的单页应用内导航。
第三部分:后端通信与数据驱动
一个功能丰富的导航栏不仅仅是静态链接的集合,它还需要与后端服务器进行通信,以获取动态数据来驱动UI的变化,例如,在“消息”图标上显示未读消息的数量角标(Badge)。本部分将聚焦于导航栏与Odoo后端的交互,讲解如何通过RPC调用模型方法,以及如何利用Odoo 18的实时总线服务(Bus Service)实现数据的实时更新。
3.1 通过RPC服务获取初始数据
当导航栏组件首次加载时,我们需要从后端获取一次初始数据,例如各个模块的待办事项数量。Odoo前端提供了rpc
服务来调用后端Python模型的公共方法。
后端Python方法定义:
在自定义模块中创建一个新的模型或扩展现有模型,来提供一个聚合数据的接口。
# custom_pwa_navbar/models/pwa_data_provider.py
from odoo import models, api
class PwaDataProvider(models.AbstractModel):
_name = 'pwa.data.provider'
_description = 'PWA Data Provider'
@api.model
def get_navbar_badge_data(self):
"""
一个RPC方法,用于聚合计算导航栏角标所需的数据。
"""
# **这里的逻辑需要根据实际业务来替换**
# 示例:获取当前用户未读消息数量
unread_messages = self.env['mail.message'].search_count([
('needaction', '=', True),
('author_id', '!=', self.env.user.partner_id.id)
])
# 示例:获取分配给当前用户的未完成任务数量
# 假设我们有一个'project.task'模型
open_tasks = self.env['project.task'].search_count([
('user_ids', 'in', self.env.user.id),
('stage_id.is_closed', '=', False)
])
return {
'messages': unread_messages,
'tasks': open_tasks,
}
前端PwaNavBar
组件调用RPC:
在PwaNavBar
组件的setup
方法中,使用useService("rpc")
钩子,并在onWillStart
生命周期钩子中调用RPC。onWillStart
是执行异步初始化操作的理想位置,因为组件会等待其中的Promise完成后再进行首次渲染。
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class PwaNavBar extends Component {
static template = "custom_pwa_navbar.PwaNavBar";
setup() {
this.rpcService = useService("rpc");
this.state = useState({
// ... (其他状态)
navItems: [
// ...
{ id: 'messages', ..., badge: 0 },
{ id: 'tasks', ..., badge: 0 },
// ...
]
});
onWillStart(async () => {
await this.fetchBadgeData();
});
}
async fetchBadgeData() {
try {
const result = await this.rpcService({
model: 'pwa.data.provider',
method: 'get_navbar_badge_data',
args: [],
kwargs: {},
});
// 更新状态,驱动UI重新渲染
const messagesItem = this.state.navItems.find(item => item.id === 'messages');
if (messagesItem) messagesItem.badge = result.messages;
const tasksItem = this.state.navItems.find(item => item.id === 'tasks');
if (tasksItem) tasksItem.badge = result.tasks;
} catch (error) {
console.error("Failed to fetch navbar badge data:", error);
}
}
// ... (其他方法)
}
3.2 利用Bus服务实现数据实时更新
仅在加载时获取一次数据是不够的。当后端数据发生变化时(例如,收到一条新消息),UI应该能够实时响应。Odoo 18的bus_service
为此提供了完美的解决方案。Odoo 18的Bus服务底层已重构为使用WebSocket,提供了比传统长轮询更高效、更低延迟的实时通信能力。
工作流程:
- 后端触发: 当特定事件发生时(如创建新任务、收到新消息),后端Python代码通过Bus服务向特定频道发送一个通知。
- 前端监听: 前端Owl组件订阅该频道,并注册一个回调函数。
- 实时更新: 当接收到通知时,回调函数被执行,可以在其中更新组件状态或重新调用RPC获取最新数据,从而刷新UI。
后端触发Bus通知:
我们需要在数据变化的地方(例如,create
或write
方法)发送通知。
# 扩展 project.task 模型
from odoo import models, api
class ProjectTask(models.Model):
_inherit = 'project.task'
def _send_task_update_notification(self, user_id):
"""发送通知到指定用户的私有频道"""
# 用户的私有频道通常是 (database_name, model_name, record_id) 或一个唯一标识符
# 这里我们使用一个自定义频道名称
channel_name = f'pwa_navbar_user_{user_id}'
message = {
'type': 'task_update',
'payload': {
'message': 'Your tasks have been updated.'
}
}
self.env['bus.bus']._sendone(channel_name, 'pwa_notification', message)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for record in records:
for user in record.user_ids:
self._send_task_update_notification(user.id)
return records
def write(self, vals):
# ... (在write方法中也需要类似的逻辑)
res = super().write(vals)
# ...
return res
前端监听Bus通知:
在PwaNavBar
组件中,使用useBus
钩子来订阅频道和处理消息。
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { useBus } from "@web/core/utils/hooks";
export class PwaNavBar extends Component {
static template = "custom_pwa_navbar.PwaNavBar";
setup() {
this.rpcService = useService("rpc");
const session = useService("session"); // 获取会话服务以拿到用户ID
this.state = useState({ /* ... */ });
onWillStart(async () => {
await this.fetchBadgeData();
});
// **实时监听逻辑**
const channelName = `pwa_navbar_user_${session.user_id}`;
useBus(this.env.bus, channelName, (notification) => {
// 收到通知后,重新获取角标数据
// 这里的notification参数就是后端发送的message
console.log("Received bus notification:", notification);
if (notification.type === 'pwa_notification' && notification.payload.type === 'task_update') {
// 收到任务更新通知,重新获取所有角标数据
this.fetchBadgeData();
}
});
}
async fetchBadgeData() { /* ... */ }
// ...
}
解析与最佳实践:
useBus
钩子:@web/core/utils/hooks
提供的useBus
钩子极大地简化了事件监听。它会自动处理组件挂载时的订阅和卸载时的取消订阅,有效防止了内存泄漏。- 频道设计: 频道的命名至关重要。为每个用户创建一个私有频道(例如
pwa_navbar_user_{user_id}
)可以确保通知被精确地发送给目标用户,避免了不必要的广播和客户端处理。 - 消息负载: 通知负载(
message
)应该设计得轻量且信息明确。一种常见的模式是,通知本身只包含一个“数据已更新”的信号,客户端收到信号后再主动发起RPC请求获取详细数据。这种方式可以确保数据的一致性,并简化后端逻辑。 - 性能考量: 虽然WebSocket性能优越,但频繁的后端触发和前端RPC调用仍需谨慎。对于变化非常频繁的数据,可以考虑在前端进行节流(throttling)或防抖(debouncing)处理,或者在后端聚合多个更新后再一次性发送通知。
通过结合一次性的RPC拉取和持续的WebSocket推送,我们的导航栏实现了高效的数据驱动,能够实时、准确地向用户展示关键信息的动态变化。
第四部分:高级UI/UX实现:动态权限、角标与转场动画
本部分将集中解决高级用户体验(UI/UX)的实现细节,包括如何根据当前用户的权限组动态渲染导航项,如何实现消息角标(Badges)的显示与更新,以及如何利用Owl的钩子和CSS技术创建平滑的视图转场动画,从而提供接近原生应用的交互感受。
4.1 动态权限:根据用户组渲染导航项
在企业级应用中,不同角色的用户应该看到不同的导航选项。实现这一点的关键在于前端能够安全地获取用户的权限信息,并据此进行条件渲染。
安全模型:后端验证,前端展示
首先必须明确一个核心安全原则:前端的任何显示/隐藏逻辑都只是为了提升用户体验,绝不能作为安全屏障。 真正的安全控制必须由Odoo后端的访问权限(Access Rights)和记录规则(Record Rules)来强制执行。即使恶意用户通过修改前端代码显示了无权访问的导航项,当他们尝试访问相应功能时,后端的RPC调用也必须因为权限不足而被拒绝。
实现步骤:
- 使用
user
服务检查权限: Odoo的user
服务提供了一个便捷的方法hasGroup(group_xml_id)
来检查当前用户是否属于某个特定的安全组。 - 在
onWillStart
中预加载权限: 与获取RPC数据类似,权限检查是异步操作,应在onWillStart
钩子中完成,以确保在组件首次渲染前权限状态已就绪。 - 使用
useState
存储权限状态: 将权限检查的结果存储在组件的state
中。 - 在模板中使用
t-if
进行条件渲染: 根据state
中的权限标志来决定是否渲染某个导航项。
示例代码 (pwa_navbar.js
):
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class PwaNavBar extends Component {
static template = "custom_pwa_navbar.PwaNavBar";
setup() {
this.userService = useService("user");
this.state = useState({
activeItem: 'home',
navItems: [
{ id: 'home', ..., requiredGroup: null }, // 'home'对所有用户可见
{ id: 'messages', ..., requiredGroup: null },
// 'tasks'导航项只对'project.group_project_user'组的成员可见
{ id: 'tasks', ..., requiredGroup: 'project.group_project_user', isVisible: false },
// 'admin'导航项只对'base.group_system'组的成员(管理员)可见
{ id: 'admin', icon: 'fa-cogs', label: '管理', action: 'some_admin_action', requiredGroup: 'base.group_system', isVisible: false }
]
});
onWillStart(async () => {
// ... (fetchBadgeData调用)
await this.checkNavPermissions();
});
}
async checkNavPermissions() {
const promises = this.state.navItems.map(async (item) => {
if (item.requiredGroup) {
// hasGroup是异步的,返回一个Promise
item.isVisible = await this.userService.hasGroup(item.requiredGroup);
} else {
item.isVisible = true;
}
});
await Promise.all(promises);
}
// ...
}
模板修改 (pwa_navbar.xml
):
<templates xml:space="preserve">
<t t-name="custom_pwa_navbar.PwaNavBar" owl="1">
<div class="o_pwa_navbar">
<t t-foreach="state.navItems" t-as="item" t-key="item.id">
<!-- 使用 t-if 指令根据 isVisible 状态进行条件渲染 -->
<t t-if="item.isVisible">
<div t-att-class="{...}" t-on-click="() => this.onNavItemClick(item)">
<!-- ... (item content) ... -->
</div>
</t>
</t>
</div>
</t>
</templates>
权限变更的实时响应:
如果用户的权限在后台被管理员修改,理想情况下UI应能实时响应。这可以通过结合第三部分讨论的Bus服务来实现。当管理员修改用户组时,后端可以发送一个Bus通知到该用户的私有频道,前端收到通知后,重新调用checkNavPermissions
方法即可刷新导航栏。
4.2 角标(Badges)的实现
角标的实现已经在第三部分中奠定了基础。核心在于将从后端获取的数据(无论是通过初始RPC调用还是通过Bus服务实时更新)绑定到UI上。PwaNavBar
组件的state.navItems
数组中的badge
属性就是数据模型,而QWeb模板中的t-if="item.badge > 0"
和<span ... t-esc="item.badge"/>
则是视图渲染。
由于Owl的响应式系统,只要this.state.navItems[...].badge
的值发生变化,UI就会自动更新,无需任何手动的DOM操作。
4.3 视图转场动画
为了提升PWA的用户体验,使其更接近原生应用,平滑的视图转场动画是必不可少的。当用户点击导航栏切换视图时,我们可以让旧视图平滑地淡出,新视图平滑地淡入。
实现策略:
我们将利用CSS动画和Owl的生命周期钩子来协同工作。动画的状态将由父组件(这里是WebClient
的patch)或一个专门的动画管理器来控制。
1. 定义CSS动画:
// 在你的主SCSS文件中
.o_action_manager {
position: relative; // 父容器需要相对定位
}
.main-content { // 假设这是客户端动作组件的根元素
// 默认样式
opacity: 1;
transform: scale(1);
transition: opacity 300ms ease-in-out, transform 300ms ease-in-out;
// 定义进入和离开的状态
&.is-entering {
opacity: 0;
transform: scale(0.95);
}
&.is-leaving {
opacity: 0;
transform: scale(1.05);
}
}
2. 在WebClient
的patch中管理动画状态:
我们需要一个更高级的actionService
包装器或直接在WebClient
中管理视图的切换过程,以便在旧视图卸载前和新视图挂载后添加/移除CSS类。
高级动画控制逻辑 (概念性,在navbar_patch.js
中扩展):
// ... (在WebClient的patch中)
patch(WebClient.prototype, {
setup() {
super.setup(...arguments);
this.actionService = useService("action");
const originalDoAction = this.actionService.doAction.bind(this.actionService);
// **包装 doAction 方法以注入动画逻辑**
this.actionService.doAction = async (action, options) => {
const actionManager = this.el.querySelector('.o_action_manager');
const currentView = actionManager ? actionManager.querySelector('.main-content') : null;
// 1. 旧视图离开动画
if (currentView) {
currentView.classList.add('is-leaving');
// 等待动画完成
await new Promise(resolve => setTimeout(resolve, 300));
}
// 2. 执行原始的 doAction,这会卸载旧视图,挂载新视图
const result = await originalDoAction(action, options);
// 3. 新视图进入动画
// 需要延迟一帧以确保新视图已渲染到DOM
requestAnimationFrame(() => {
const newView = actionManager ? actionManager.querySelector('.main-content') : null;
if (newView) {
newView.classList.add('is-entering');
// 强制重绘
newView.getBoundingClientRect();
// 移除class以触发动画
newView.classList.remove('is-entering');
}
});
return result;
};
}
});
解释与挑战:
- 动画协调: 上述代码是一个简化的概念。真正的挑战在于精确协调DOM操作和CSS动画。旧视图的
is-leaving
动画必须在它被从DOM中移除之前完成。setTimeout
是一种简单但不够精确的方法,更可靠的方式是监听animationend
或transitionend
事件。 - 组件通信:
WebClient
需要知道哪个是“当前视图”。这通常需要深入理解ActionManager
的内部工作原理,或者让客户端动作组件在onMounted
和onWillUnmount
时向其父组件(或全局服务)注册/注销自己。 - 封装为自定义钩子: 为了可重用性和代码整洁,可以将这套复杂的动画逻辑封装成一个自定义Owl钩子,例如
useViewTransition()
。这个钩子可以在WebClient
中使用,负责包装actionService
并管理CSS类。
一个更可靠的、基于生命周期钩子的方法:
// 在你的客户端动作组件基类或mixin中
// MyBaseClientAction.js
import { onMounted, onWillUnmount } from "@odoo/owl";
export const transitionMixin = {
setup() {
// ...
onMounted(() => {
this.el.classList.add('is-entering');
requestAnimationFrame(() => {
this.el.classList.remove('is-entering');
});
});
onWillUnmount(async () => {
this.el.classList.add('is-leaving');
// 创建一个Promise,在动画结束后resolve
await new Promise(resolve => {
const onAnimationEnd = () => {
this.el.removeEventListener('transitionend', onAnimationEnd);
resolve();
};
this.el.addEventListener('transitionend', onAnimationEnd);
});
});
}
};
// 在你的实际客户端动作组件中使用
// pwa_tasks_view.js
class PwaTasksView extends Component {
static template = "...";
setup() {
Object.assign(this, transitionMixin);
this.setup(); // 调用mixin的setup
// ... 你的组件逻辑
}
}
这种基于Mixin或组合的方式将动画逻辑内聚到组件自身,但需要Odoo的ActionManager
在卸载组件时等待onWillUnmount
中的异步操作完成。这需要对Odoo 18的ActionManager
行为进行验证。
通过上述高级UI/UX技术的实现,我们的PWA导航栏不仅功能强大,而且在视觉和交互层面都提供了流畅、专业且接近原生应用的用户体验。
第五部分:状态管理与性能优化
当应用变得复杂,多个组件需要共享和响应同一份数据时(如用户权限、导航状态、角标数据),仅靠组件内部的useState
进行状态管理会变得混乱且难以维护。本部分将探讨在复杂交互场景下的全局状态管理策略,例如使用Owl Store来集中管理应用状态,并讨论确保组件在频繁更新和复杂动画下依然保持高性能的优化技巧。
5.1 全局状态管理:引入Owl Store
动机:
- 状态共享: 导航栏需要知道当前激活的路由,而主内容区的视图也可能需要改变导航栏的状态(例如,在某个操作后更新角标)。
- 单一数据源 (Single Source of Truth): 将共享状态集中存放在一个地方,可以避免数据不一致的问题,使应用状态更可预测。
- 逻辑解耦: 将状态管理逻辑从UI组件中分离出来,使组件更专注于渲染,而Store则负责业务逻辑。
Owl Store 概念 (基于其设计理念和对Vuex/Redux的借鉴):
虽然Odoo 18的官方文档对@odoo/owl/store
的API细节着墨不多,但基于Owl的设计哲学,我们可以推断并设计一个类似于Vuex/Redux的Store。一个典型的Owl Store会包含:
- State: 存储应用状态的响应式对象。
- Actions: 处理异步操作(如RPC调用)和复杂业务逻辑的方法。Actions不能直接修改State。
- Mutations (或直接在Action中修改): 唯一可以修改State的同步函数。在更现代的状态管理器中,可能会允许Action直接修改State。
- Getters: 从State中派生出的计算属性,类似于模型的计算字段。
构建模块化的Store:
为了保持可维护性,我们将Store按功能领域进行拆分。
Store目录结构:
custom_pwa_navbar/
└── static/
└── src/
└── store/
├── index.js # 组合所有store模块
├── nav_store.js # 导航栏相关状态
└── user_store.js # 用户权限和信息
nav_store.js
示例:
// custom_pwa_navbar/static/src/store/nav_store.js
export const navStoreModule = {
state: {
activeItem: 'home',
navItems: [
// ... navItems definition ...
],
},
actions: {
async setActiveItem({ state, dispatch }, itemId) {
if (state.activeItem === itemId) return;
state.activeItem = itemId;
// 可能需要触发其他action,例如路由切换
},
async fetchBadges({ state, root }, rpc) {
const data = await rpc({ model: 'pwa.data.provider', method: 'get_navbar_badge_data' });
state.navItems.find(i => i.id === 'messages').badge = data.messages;
state.navItems.find(i => i.id === 'tasks').badge = data.tasks;
},
},
getters: {
getTaskBadge(state) {
const taskItem = state.navItems.find(i => i.id === 'tasks');
return taskItem ? taskItem.badge : 0;
},
},
};
index.js
(组合Store并创建实例):
// custom_pwa_navbar/static/src/store/index.js
import { createStore } from "@odoo/owl/store"; // **假设API**
import { navStoreModule } from "./nav_store";
import { userStoreModule } from "./user_store";
export function setupStore(env) {
const store = createStore({
modules: {
nav: navStoreModule,
user: userStoreModule,
},
env,
});
return store;
}
推测与假设: 上述createStore
和模块化的API是基于现代状态管理库的通用模式推断的。在实际开发中,需要查阅Odoo 18或Owl的最新官方文档或源代码以确认其准确的API。如果Owl Store不提供模块化,我们可以手动将多个状态对象组合成一个大的state
对象。
在组件中使用Store:
组件可以通过useStore
钩子或从env
中访问Store,并使用选择器(selector)来订阅状态变化。
// pwa_navbar.js
import { useStore } from "@odoo/owl"; // **假设API**
// ...
setup() {
// ...
this.store = useStore();
// 使用选择器订阅状态,当返回值变化时,组件会重渲染
this.activeItem = useStore(state => state.nav.activeItem);
this.navItems = useStore(state => state.nav.navItems);
}
onNavItemClick(item) {
// 派发action来改变状态
this.store.dispatch('nav/setActiveItem', item.id);
this.actionService.doAction(item.action);
}
5.2 状态持久化
对于PWA,即使用户关闭并重新打开浏览器,某些状态(如UI偏好)也应该被保留。我们可以使用浏览器存储API将Store的某些部分持久化。
存储机制 |
容量 |
持久性 |
API类型 |
适用场景 |
|
~5-10MB |
跨会话持久 |
同步 |
少量、非敏感数据,如主题、用户偏好。 |
|
~5-10MB |
单个会话 |
同步 |
临时数据,如表单草稿,当前标签页的UI状态。 |
|
几乎无限制 |
跨会话持久 |
异步 |
大量、复杂、结构化数据,离线数据缓存,是PWA的首选。 |
实现策略:
可以创建一个Store插件或在Store的订阅机制中,监听状态变化,并将需要持久化的部分同步到localStorage
或IndexedDB
。应用启动时,从存储中读取数据来初始化Store的state
。
// 在Store创建逻辑中
// 1. 初始化时从localStorage加载状态
const persistedNavState = JSON.parse(localStorage.getItem('pwa_nav_state'));
if (persistedNavState) {
// 合并到初始状态
Object.assign(navStoreModule.state, persistedNavState);
}
// 2. 订阅状态变化并保存
store.subscribe((mutation, state) => {
// 只持久化部分状态
const stateToPersist = { activeItem: state.nav.activeItem };
localStorage.setItem('pwa_nav_state', JSON.stringify(stateToPersist));
});
5.3 性能优化技巧
当Store状态频繁变化时,可能会导致不必要的组件重渲染,影响性能,尤其是在执行动画时。
- 使用精细化的选择器 (
useSelector
-like):
组件应该只订阅它们真正需要的数据片段,而不是整个对象。
-
- 不好:
const user = useStore(state => state.user);
当user
对象中任何不相关的属性(如user.lastLogin
)变化时,组件都会重渲染。 - 好:
const userName = useStore(state => state.user.name);
只有当userName
这个原始值变化时,组件才会重渲染。
- 不好:
- Memoized Selectors (记忆化选择器):
对于需要从State中进行复杂计算派生出的数据,应该使用记忆化选择器(类似Reselect
库的思想)。这可以缓存计算结果,只有当其依赖的原始State部分发生变化时,才重新计算。
概念示例:
import { createSelector } from 'reselect'; // 概念库
const getTasks = state => state.tasks.allTasks;
const getActiveFilter = state => state.tasks.filter;
// 这个选择器只有在allTasks或filter变化时才会重新计算
const getVisibleTasks = createSelector(
[getTasks, getActiveFilter],
(tasks, filter) => {
switch (filter) {
case 'COMPLETED': return tasks.filter(t => t.completed);
// ...
default: return tasks;
}
}
);
即使Owl Store没有内置createSelector
,我们也可以手动实现这个缓存逻辑,或者使用简单的useMemo
钩子(如果Owl提供)在组件级别缓存计算结果。
- 组件级别的Memoization:
对于纯展示性组件,如果其props
没有变化,就不应该重新渲染。React为此提供了React.memo
,Owl可能也提供了类似的机制来包裹组件,避免不必要的渲染。
- 对动画性能的影响:
确保状态更新不会不必要地触发正在进行动画的组件或其父组件的重渲染。将动画状态(如is-entering
)与业务数据状态分离,并使用精细化的选择器,是避免动画卡顿的关键。
通过引入结构化的Owl Store进行全局状态管理,并结合持久化和一系列性能优化技术,我们可以构建一个既健壮、可维护,又高度响应和流畅的复杂PWA前端应用。
第六部分:PWA集成与生命周期管理
本部分将专门讨论导航栏在PWA环境下的特殊考量,特别是如何通过定制Service Worker来实现高级离线功能,确保导航栏及其驱动的功能在网络连接不可靠甚至完全离线的情况下依然可用。我们将超越简单的静态资源缓存,探索一个完整的离线请求队列和后台同步机制。
6.1 Service Worker定制与离线缓存
Odoo 18允许通过自定义模块来提供manifest.json
和service-worker.js
,从而启用和定制PWA功能。
基础:静态资源缓存
Service Worker的核心功能之一是作为网络代理,拦截网络请求并从缓存中提供响应。
service-worker.js
缓存策略示例:
// custom_pwa_navbar/static/src/js/service-worker.js
const CACHE_NAME = 'pwa-navbar-cache-v1';
const URLS_TO_CACHE = [
'/',
'/web',
// ... Odoo核心JS/CSS资源的路径 (需要小心管理)
// ... 导航栏组件相关的JS/XML/CSS资源
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(URLS_TO_CACHE);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Cache-first策略
if (response) {
return response;
}
return fetch(event.request);
})
);
});
注意: Odoo的资源URL可能包含哈希值,简单的静态列表缓存可能不够健壮。更高级的策略是使用Workbox.js库或动态缓存成功的新请求。
6.2 高级离线功能:RPC请求队列
真正的离线体验意味着用户可以执行写操作(如创建任务、发送消息),这些操作在离线时被暂存,在网络恢复时自动同步到服务器。
架构设计:
- 拦截RPC调用: 我们需要一个机制来拦截所有从前端发出的RPC请求。最佳方式是包装或修补Odoo的
rpc_service
。 - 网络状态检测: 检查
navigator.onLine
状态。如果在线,正常发送RPC。 - 请求入队: 如果离线,将RPC请求的详细信息(模型、方法、参数)序列化并存入
IndexedDB
中的一个“待办队列”表。 - 后台同步触发: 注册一个
background sync
事件。当网络恢复时,浏览器会自动唤醒Service Worker来处理这个同步事件。 - Service Worker处理队列: 在
sync
事件处理器中,Service Worker从IndexedDB
读取队列中的请求,并按顺序将它们重新发送到服务器。 - 结果反馈: Service Worker通过
postMessage
将同步结果(成功或失败)通知给前端UI,UI可以据此更新状态或显示通知。
实现细节:
1. 包装rpc_service
(在navbar_patch.js
或新文件中):
// **推测性实现,需要验证Odoo服务注册表的行为**
import { registry } from "@web/core/registry";
import { rpcService }s from "@web/core/rpc_service";
import { openDB } from 'idb'; // 使用idb库简化IndexedDB操作
const dbPromise = openDB('pwa-request-queue-db', 1, {
upgrade(db) {
db.createObjectStore('requests', { keyPath: 'id', autoIncrement: true });
},
});
const offlineRpcService = {
...rpcService,
async rpc(route, params, settings) {
if (!navigator.onLine) {
console.log("Offline: Queuing RPC request", { route, params });
const db = await dbPromise;
await db.add('requests', { route, params, timestamp: new Date().getTime() });
// 注册后台同步
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('rpc-queue-sync');
// **立即向UI返回一个乐观的响应**
// 这对于提升用户体验至关重要,让用户感觉操作已“完成”
return Promise.resolve({ optimistic: true, message: "请求已加入队列,将在网络恢复后同步。" });
}
return rpcService.rpc.call(this, route, params, settings);
}
};
// 使用更高优先级注册或直接patch来覆盖默认的rpc_service
registry.category("services").add("rpc", offlineRpcService, { force: true });
2. Service Worker处理后台同步:
// service-worker.js
import { openDB } from 'idb';
self.addEventListener('sync', event => {
if (event.tag === 'rpc-queue-sync') {
event.waitUntil(syncRpcQueue());
}
});
async function syncRpcQueue() {
const db = await openDB('pwa-request-queue-db', 1);
const allRequests = await db.getAll('requests');
for (const req of allRequests) {
try {
// **这里的fetch需要模拟Odoo的RPC调用结构**
const response = await fetch(req.route, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', method: 'call', params: req.params })
});
if (response.ok) {
await db.delete('requests', req.id);
// 通知UI同步成功
notifyClients({ type: 'SYNC_SUCCESS', payload: { id: req.id } });
} else {
// 处理错误情况,例如保留请求并重试
notifyClients({ type: 'SYNC_ERROR', payload: { id: req.id, error: 'Server error' } });
}
} catch (error) {
// 网络错误,保留请求,下次同步时重试
notifyClients({ type: 'SYNC_ERROR', payload: { id: req.id, error: 'Network error' } });
break; // 停止处理队列,等待下一次sync事件
}
}
}
async function notifyClients(message) {
const clients = await self.clients.matchAll();
for (const client of clients) {
client.postMessage(message);
}
}
3. 前端UI监听Service Worker消息:
在PwaNavBar
或一个全局的UI服务中监听消息。
// PwaNavBar.js setup()
navigator.serviceWorker.addEventListener('message', event => {
const { type, payload } = event.data;
if (type === 'SYNC_SUCCESS') {
// 显示一个短暂的“同步成功”通知
this.env.services.notification.add("数据已同步至服务器。", { type: "success" });
// 可能需要重新获取数据以刷新UI
this.store.dispatch('nav/fetchBadges');
} else if (type === 'SYNC_ERROR') {
this.env.services.notification.add(`数据同步失败: ${payload.error}`, { type: "danger" });
}
});
6.3 挑战与高级考量
- 请求依赖性: 如果请求之间存在依赖关系(例如,必须先创建订单,然后才能添加订单行),简单的顺序执行可能不够。需要在队列中记录依赖关系,或者设计更复杂的同步逻辑。
- 数据冲突: 当多个客户端或用户同时修改同一份数据时,可能会发生冲突。需要设计冲突解决策略(例如,最后写入者获胜 Last-Write-Wins,或更复杂的三向合并算法)。
- 认证与会话: Service Worker的生命周期独立于页面。需要确保Service Worker在执行后台同步时,拥有有效的用户认证令牌或会话信息来与Odoo后端通信。
通过实施这套完整的离线请求队列和后台同步机制,我们的Odoo PWA将具备强大的韧性,即使在最不稳定的网络环境下,也能为用户提供无缝、可靠的操作体验,真正实现了渐进式网络应用的承诺。