HarmonyOS 6.1 PC应用实战:键鼠协同与多窗体——电商后台管理系统“上车”
系列番外·生产力工具篇。车机篇发布后,有开发者问:“手机、车机都写了,那传说中的鸿蒙PC呢?咱们这个电商Demo能不能搞个PC版的后台管理系统?” 问到点子上了!随着HarmonyOS NEXT向PC端延伸,一次开发,多端部署的价值将被最大化。今天我们将电商Demo的“后台管理端”适配到PC平台。重点攻克三大PC开发独有的难关:键鼠交互范式(Hover、右键、拖拽)、多窗体架构(主窗口+子窗口+弹窗)、响应式布局进阶(从移动端单列到PC端多列+侧边栏)。全程基于API23,含官方文档未涉及的PC端UX设计规范。
一、前言:为什么PC端是鸿蒙生态的“临门一脚”?
在移动端,我们习惯了触摸交互和全屏应用。但在PC端,用户的心智模型完全不同:
-
效率优先:用户使用键盘快捷键(Ctrl+C/V)、鼠标精确点击、多窗口并排来提升效率。
-
空间充裕:PC拥有大屏幕(1920x1080起),可以容纳更多信息密度,不再需要“隐藏式菜单”。
-
多任务并行:用户希望一边看数据报表,一边回客服消息,一边查商品库存。
我们的电商Demo之前只有“C端用户版”(手机App),缺少“B端商家版”(后台管理)。今天,我们利用ArkUI的跨平台能力,在不重写业务逻辑的前提下,快速构建一个运行在PC上的电商后台管理系统。这将充分展示ArkUI-X和HarmonyOS NEXT的威力:一套代码,跑在手机、车机和PC上。
二、核心概念辨析(移动端思维 vs PC端思维)
很多开发者直接把手机UI拉伸到PC屏幕上,结果惨不忍睹。必须先转变思维:
|
维度 |
移动端(手机/车机) |
PC端(桌面/笔记本) |
|---|---|---|
|
交互核心 |
触摸(粗放) |
键鼠(精细) + 触摸(二合一设备) |
|
导航结构 |
底部Tab栏 + 页面栈 |
顶部菜单栏 + 侧边导航栏 + 多标签页 |
|
内容布局 |
单列流式布局 |
多列栅格布局 + 分栏布局(Master-Detail) |
|
窗口形态 |
强制全屏 |
自由窗口(可缩放、最小化、最大化) |
|
关键事件 |
onClick, onTouch |
onHover, onKeyEvent, onDrop, onDrag |
💡 核心认知:PC端开发不仅仅是屏幕变大了,而是交互逻辑的根本改变。我们需要为鼠标悬停(Hover)提供视觉反馈,为键盘快捷键提供响应逻辑,为窗口大小变化提供动态布局。
三、代码实现:从“手持电商”到“桌面后台”
3.1 环境准备与配置
-
新建PC模块:在DevEco Studio中,右键项目 -> New -> Module -> PC Module。
-
设备类型声明:修改
module.json5,声明支持PC。{ "module": { "deviceTypes": ["pc"], // 声明为PC应用 "abilities": [ { "name": "EntryAbility", "window": { "designWidth": 1920, // 设计稿基准宽度 "autoDesignWidth": true, "maxWindowRatio": 3.0, // 最大窗口比例 "minWindowRatio": 0.5 // 最小窗口比例 } } ] } } -
资源目录:在
resources下新建layout-pc目录,存放PC专属布局。
3.2 构建PC端经典布局(侧边栏+主内容区)
创建entry_pc/src/main/ets/pages/AdminIndex.ets。采用经典的“主-详”布局。
import { router } from '@kit.ArkUI'
import { CommonConstants } from '../common/Constants'
// 定义菜单项接口
interface MenuItem {
id: string
icon: Resource
label: string
route: string
}
@Entry
@Component
struct AdminIndex {
@State currentMenu: string = 'dashboard'
@State windowWidth: number = 1920 // 监听窗口宽度
private menus: MenuItem[] = [
{ id: 'dashboard', icon: $r('app.media.ic_dashboard'), label: '数据看板', route: 'pages/admin/Dashboard' },
{ id: 'products', icon: $r('app.media.ic_goods'), label: '商品管理', route: 'pages/admin/ProductList' },
{ id: 'orders', icon: $r('app.media.ic_order'), label: '订单管理', route: 'pages/admin/OrderList' },
{ id: 'settings', icon: $r('app.media.ic_settings'), label: '系统设置', route: 'pages/admin/Settings' }
]
aboutToAppear(): void {
// 监听窗口尺寸变化,实现响应式布局
window.getLastWindow(getContext(this)).then((win) => {
win.on('windowSizeChange', (size) => {
this.windowWidth = size.width
})
})
}
build() {
Row() {
// 左侧:固定导航栏(PC专属)
Column() {
// Logo
Text('Shop Admin')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#0A59F7')
.margin({ bottom: 40 })
// 菜单列表
List() {
ForEach(this.menus, (menu: MenuItem) => {
Row() {
Image(menu.icon)
.width(24)
.height(24)
.fillColor(this.currentMenu === menu.id ? '#0A59F7' : '#666')
if (this.windowWidth > 1200) { // 宽屏显示文字,窄屏只显示图标
Text(menu.label)
.fontSize(16)
.fontColor(this.currentMenu === menu.id ? '#0A59F7' : '#333')
.margin({ left: 12 })
}
}
.width('100%')
.padding(16)
.backgroundColor(this.currentMenu === menu.id ? '#E8F0FE' : Color.Transparent)
.borderRadius(12)
.onClick(() => {
this.currentMenu = menu.id
// PC端通常是单页应用(SPA),这里用路由替换内容区,而不是打开新页面
router.replacePath({ url: menu.route })
})
.onHover((isHover) => {
// 鼠标悬停效果(PC核心交互)
if (isHover && this.currentMenu !== menu.id) {
// 可以在这里改变背景色,提供反馈
}
})
})
}
.layoutWeight(1)
// 底部用户信息
Row() {
Image($r('app.media.avatar'))
.width(40)
.height(40)
.borderRadius(20)
if (this.windowWidth > 1200) {
Column() {
Text('Admin')
.fontSize(14)
.fontWeight(FontWeight.Medium)
Text('admin@shop.com')
.fontSize(12)
.fontColor('#999')
}
.margin({ left: 12 })
.layoutWeight(1)
}
}
.padding(16)
}
.width(this.windowWidth > 1200 ? 240 : 80) // 响应式宽度
.height('100%')
.backgroundColor('#F8F9FA')
.padding(16)
.transition(TransitionEffect.OPACITY.animation({ duration: 300 })) // 平滑过渡
// 右侧:主内容区
Column() {
// 顶部工具栏(面包屑+搜索+快捷键提示)
Row() {
Text(this.menus.find(m => m.id === this.currentMenu)?.label || '')
.fontSize(20)
.fontWeight(FontWeight.Medium)
Blank()
TextInput({ placeholder: '搜索商品/订单...' })
.width(300)
.height(40)
.backgroundColor('#F0F2F5')
.borderRadius(20)
.padding({ left: 16 })
Text('⌨️ Ctrl+K')
.fontSize(12)
.fontColor('#999')
.margin({ left: 10 })
}
.width('100%')
.padding(16)
.border({ width: { bottom: 1 }, color: '#EEE' })
// 内容区(根据菜单切换)
Stack() {
// 这里根据currentMenu加载不同的Component
// 例如:Dashboard, ProductList等
this.ContentArea()
}
.layoutWeight(1)
.backgroundColor('#FFFFFF')
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
.onKeyEvent((event: KeyEvent) => {
// PC端键盘快捷键支持
if (event.type === KeyType.Down) {
// Ctrl + K 聚焦搜索框
if (event.ctrlKey && event.keyCode === KeyCode.KEY_K) {
// 实际开发中,这里需要拿到TextInput的Controller并调用focus()
promptAction.showToast({ message: '搜索快捷键触发' })
return true // 阻止事件冒泡
}
// ESC 返回仪表盘
if (event.keyCode === KeyCode.KEY_ESCAPE) {
this.currentMenu = 'dashboard'
return true
}
}
return false
})
}
/**
* 根据当前菜单渲染内容区
*/
@Builder
ContentArea() {
if (this.currentMenu === 'products') {
// 复用手机端的商品列表逻辑,但改变UI布局
this.ProductManagementTable()
} else {
// 其他页面的占位符
Text('内容加载中...')
.fontSize(16)
.fontColor('#999')
}
}
/**
* PC端商品管理表格(替代手机端的List)
*/
@Builder
ProductManagementTable() {
List() {
// 表头
ListItem() {
Row() {
Text('商品名称').layoutWeight(3)
Text('价格').layoutWeight(1)
Text('库存').layoutWeight(1)
Text('状态').layoutWeight(1)
Text('操作').layoutWeight(2)
}
.padding(16)
.backgroundColor('#F8F9FA')
.fontWeight(FontWeight.Medium)
}
// 数据行
ForEach(this.getProducts(), (item: GoodsBean) => {
ListItem() {
Row() {
// 商品名+图
Row() {
Image(item.image).width(48).height(48).borderRadius(8).objectFit(ImageFit.Cover)
Text(item.name).margin({ left: 12 }).maxLines(1)
}.layoutWeight(3)
Text(`¥${item.price}`).layoutWeight(1)
Text(item.stock.toString()).layoutWeight(1)
Text(item.status === 1 ? '在售' : '下架')
.layoutWeight(1)
.fontColor(item.status === 1 ? '#4CAF50' : '#F44336')
// 操作按钮组
Row() {
Button('编辑').height(32).backgroundColor('#E8F0FE').fontColor('#0A59F7')
Button('下架').height(32).backgroundColor('#FFF3E0').fontColor('#FF9800').margin({ left: 8 })
}.layoutWeight(2)
}
.padding(16)
.border({ width: { bottom: 1 }, color: '#EEE' })
.onHover((isHover) => {
// 鼠标悬停高亮行
// 实际开发中,可以通过状态变量控制背景色
})
// PC端支持右键菜单
.onContextMenu((event: ContextMenuEvent) => {
this.showContextMenu(event.x, event.y, item)
})
}
})
}
.sticky(StickyStyle.Header) // 表头吸顶
.width('100%')
.height('100%')
}
/**
* 显示右键菜单(PC专属)
*/
showContextMenu(x: number, y: number, item: GoodsBean): void {
ContextMenu.show({
x: x,
y: y,
menuItems: [
{ title: '复制商品ID', icon: $r('app.media.ic_copy'), action: () => console.log(item.id) },
{ title: '查看详情', icon: $r('app.media.ic_view'), action: () => router.pushUrl({ url: 'pages/admin/ProductDetail', params: { id: item.id } }) },
{ divider: true },
{ title: '删除商品', icon: $r('app.media.ic_delete'), fontColor: '#F44336', action: () => this.deleteProduct(item.id) }
]
})
}
// 模拟数据
getProducts(): GoodsBean[] {
return [
{ id: 1, name: 'HarmonyOS 6.1 定制款手机', price: 5999, stock: 99, status: 1, image: 'phone.jpg' },
{ id: 2, name: '鸿蒙智行 车载充电器', price: 199, stock: 500, status: 1, image: 'charger.jpg' },
{ id: 3, name: '星闪无线鼠标', price: 299, stock: 0, status: 0, image: 'mouse.jpg' }
]
}
deleteProduct(id: number): void {
promptAction.showDialog({
title: '确认删除',
message: `确定要删除商品ID: ${id} 吗?`,
buttons: [{ text: '取消' }, { text: '删除', color: '#F44336' }]
}).then((result) => {
if (result.index === 1) {
// 执行删除逻辑
promptAction.showToast({ message: '删除成功' })
}
})
}
}
3.3 关键PC交互实现
上面的代码展示了三个关键的PC交互:
-
Hover(悬停):
.onHover()。当鼠标移到列表行上时,可以改变背景色,给用户即时的视觉反馈。 -
Context Menu(右键菜单):
.onContextMenu()。PC用户习惯右键操作,这里替代了手机上的长按菜单。 -
Keyboard Shortcuts(键盘快捷键):
.onKeyEvent()。监听Ctrl+K、ESC等组合键,大幅提升操作效率。
四、踩坑记录(官方文档没写的5个PC坑)
-
窗口尺寸监听:
windowSizeChange回调非常频繁,如果在回调里直接做重计算(如重新构建整个UI树),会导致界面卡顿。解决:使用防抖(Debounce)或节流(Throttle)技术,限制更新频率(如200ms一次)。 -
焦点管理(Focus):PC端有“焦点”的概念。点击一个按钮后,焦点可能还在按钮上,此时按空格键会再次触发点击。在输入框失去焦点时,需要手动管理焦点状态。
-
拖拽文件上传:PC端常见需求是拖拽文件到窗口上传。
onDrop事件需要处理DragEvent,从中提取文件的URI(event.items[0].uri),然后通过文件管理API读取。官方文档对此流的介绍比较分散。 -
多窗口层级:PC端可能有多个窗口(如主窗口、设置窗口、弹窗)。
promptAction.showDialog默认是模态的,会阻塞主窗口。对于非阻塞通知,应该使用Toast或自定义的Overlay。 -
字体渲染:PC端屏幕PPI差异巨大(从普通显示器到4K屏)。使用
fp单位(字体像素)而不是px,确保字体在不同DPI下物理大小一致。
更多推荐




所有评论(0)