更新记录
1.0.1(2026-08-10)
无
1.0.0(2026-08-07)
初始版本发布
平台兼容性
uni-app(3.8.1)
| Vue2 | Vue3 | Chrome | Safari | app-vue | app-nvue | Android | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|
| × | √ | × | × | × | √ | 9.0 | × | × |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| × | × | × | × | × | × | × | × | × | × | × | × |
其他
| 多语言 | 暗黑模式 | 宽屏模式 |
|---|---|---|
| × | × | √ |
zxz-tv-kit
TV 端焦点导航与布局缩放框架(Zone-Grid 焦点模型,nvue 专用)。
用于 Android TV / 投影仪等只有遥控器、没有触摸的设备:用方向键在页面元素间移动高亮框,用确认键激活。本 kit 负责「按了下键,焦点应该去哪」这件事,以及「设计稿 960×540,到 1920×1080 电视上怎么放大」这件事。
目录
它解决什么问题
浏览器里用鼠标点击,元素在哪不重要。但在电视上,用户按「下」键时,你必须回答:下一个焦点是哪个元素?
常见做法是运行时测量所有元素的 DOM 坐标,再按方向做空间评分搜索。这套方案在 nvue + TV 上有三个致命问题:
| 问题 | 后果 |
|---|---|
dom.getComponentRect 异步且开销大 |
首次按键延迟 \~200ms,遥控器体感明显黏滞 |
| 滚动后坐标失效 | 列表滚一屏后,导航跳到错误元素 |
| 搜索范围要靠调参 | 跨区域(如从网格跳到分页器)时常失败 |
本 kit 的思路:不测量,用算的。
TV 布局本质上就是网格和列表。只要你声明「这块区域是 5 列网格」,那么第 7 个元素往右就是第 8 个,往下就是第 12 个——纯整数运算,O(1),0ms 延迟,且不受滚动影响。
核心概念
理解这三个词,就理解了整个 kit:
1. Zone(区域)
一块布局规则一致的区域。比如首页可以划成 5 个 Zone:
┌──────────────────────────────────────────┐
│ [search] [fav] │ ← 两个独立 Zone
├───────┬──────────────────────────────────┤
│ │ ■ ■ ■ ■ ■ │
│ [cat] │ ■ ■ ■ ■ ■ ← zone: 'mov' │
│ 垂直 │ ■ ■ ■ ■ ■ grid, columns: 5 │
│ 列表 │ │
├───────┴──────────────────────────────────┤
│ [pager] 上一页 1 2 3 … 下一页 跳至 __ │ ← 水平 Zone
└──────────────────────────────────────────┘
Zone 声明自己的 layout(grid / vertical / horizontal)、列数、以及撞到边界时该去哪个 Zone。
2. index(序号)
元素在 Zone 内的序号,从 0 开始。这是坐标的唯一来源——不存在「x/y 坐标」的概念,位置完全由 index + Zone 规则推导:
// grid, columns = 5,index = 7 的元素:
row = Math.floor(7 / 5) = 1 // 第 1 行
col = 7 % 5 = 2 // 第 2 列
// 按「右」→ col+1 = 3 → index = 1*5+3 = 8
// 按「下」→ row+1 = 2 → index = 2*5+2 = 12
⚠️
index必须与视觉顺序严格一致,否则导航会跳到肉眼看起来不相邻的元素。这是使用本 kit 最常见的出错点。
3. pageId(页面标识)
焦点状态按 pageId 分组隔离。nvue 里「详情页」常常只是同一个 nvue 文件里 v-if 出来的一个组件,并不是真的 uni-app 页面。用 pageId 就能让浏览页和详情页各自维护一套焦点,互不干扰:
// 浏览组件用 'ZINDEX',播放器组件用 'ZDETAIL'
ZoneFocusManager.activatePage('ZDETAIL', { zone: 'player', index: 0 })
// 此时 'ZINDEX' 的所有注册都还在,只是不再响应按键;返回时 activatePage('ZINDEX') 即可恢复原焦点
安装
将 uni_modules/zxz-tv-kit 整个目录放入项目。两个组件(zxz-focusable / zxz-paginator)经 easycom 自动注册,模板里直接写标签即可,无需 import、无需 components 声明。
运行环境要求:
| 项 | 要求 |
|---|---|
| 渲染引擎 | nvue 专用(vue 页面不支持) |
| 平台 | App(Android TV / 投影仪);按键依赖 plus.key,H5/小程序不可用 |
| Vue | Vue 3 |
| HBuilderX | ≥ 3.6.0 |
5 分钟上手
第 1 步:装载插件(main.js,可选)
import { createTvKit } from '@/uni_modules/zxz-tv-kit'
app.use(createTvKit())
这一步只是注册全局属性 this.$tvKit(内含 ZoneFocusManager / px / createStyles)。不装也能用,直接 import 即可。
插件不会自动注册按键监听。因为
plus.key是全局的,app 启动就注册会导致非 TV 页面也被抢键。必须由 TV 页面自己在onLoad里装、onUnload里卸。
第 2 步:写一个 TV 页面(.nvue)
<template>
<view>
<!-- 一个垂直列表,3 个可聚焦项 -->
<zxz-focusable v-for="(item, i) in list" :key="item.id"
:item-id="'row-' + item.id" zone="list" :index="i" :page-id="pageId"
@focus="onItemFocus" @blur="onItemBlur" @activate="(item)">
<!-- 高亮样式由你自己画:kit 只告诉你「谁被聚焦了」 -->
<view :style="[styles.row, focusedId === 'row-' + item.id ? styles.rowFocused : {}]">
<text :style="styles.text">{{ item.name }}</text>
</view>
</zxz-focusable>
</view>
</template>
<script>
import {
initKeyListener, removeKeyListener,
ZoneFocusManager, createStyles
} from '@/uni_modules/zxz-tv-kit'
export default {
data() {
return {
pageId: 'demo',
focusedId: null,
list: [{ id: 1, name: '第一项' }, { id: 2, name: '第二项' }, { id: 3, name: '第三项' }]
}
},
computed: {
// createStyles:数字自动按设计稿缩放成实际 px
styles() {
return createStyles({
row: { height: 48, paddingLeft: 20, justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.1)', marginBottom: 8 },
rowFocused: { backgroundColor: 'rgba(255,255,255,0.5)' },
text: { fontSize: 16, color: '#ffffff' }
})
}
},
onLoad() {
initKeyListener() // ① 接管遥控器按键
},
onUnload() {
removeKeyListener() // ② 交还按键
ZoneFocusManager.clearPage(this.pageId) // ③ 清空注册,防止元素残留
},
mounted() {
this.registerZones() // ④ 先声明区域
this.$nextTick(() => { // ⑤ 等元素注册完,再给默认焦点
ZoneFocusManager.activatePage(this.pageId, { zone: 'list', index: 0 })
})
},
methods: {
registerZones() {
ZoneFocusManager.registerZone(this.pageId, {
id: 'list',
layout: 'vertical'
})
},
onItemFocus({ itemId }) { this.focusedId = itemId }, // 注意:对象
onItemBlur(itemId) { // 注意:字符串
if (this.focusedId === itemId) this.focusedId = null
},
(item) { uni.showToast({ title: '打开 ' + item.name }) }
}
}
</script>
生命周期顺序(重要)
这个顺序错了就会「焦点不出现」或「按键无反应」:
onLoad → initKeyListener()
mounted → registerZone() ← 先声明 Zone
↓ (子组件 mounted 自动 registerItem)
$nextTick → activatePage(默认焦点) ← 元素就位后才聚焦
...
onUnload → removeKeyListener() + clearPage()
zxz-focusable 在自己的 mounted 里自动向管理器注册,你不用管。若数据是异步来的(接口还没返回,列表是空的),activatePage 传的默认焦点会被存为 pendingFocus,等元素真正注册上来时自动补聚焦——所以不需要自己 setTimeout 等数据。
Zone 配置详解
ZoneFocusManager.registerZone(pageId, {
id: 'mov', // 【必填】区域唯一标识
layout: 'grid', // 【必填】'grid' | 'vertical' | 'horizontal'
columns: 5, // grid 列数(layout='grid' 时必填,默认 1)
// ↓ 以下三项仅「焦点跟随滚动」时需要,填设计稿原始值,内部自动 px() 缩放
origin: { x: 0, y: 14 }, // 区域左上角在滚动容器内的起点
itemSize: { w: 140, h: 190 }, // 单个元素尺寸
gap: { x: 0, y: 0 }, // 元素间距(默认 0)
// 焦点跟随滚动:焦点移到可视区外时,自动改写你的 scrollTop 变量
scroll: {
getter: () => this.movieScrollTop,
setter: (v) => { this.movieScrollTop = v },
viewport: () => this.listHeight // 可视区高度(横向列表则为宽度)
},
// 撞到本区域边界时,跳到哪个 Zone
focusGuide: {
up: { zone: 'search', index: 0 },
left: { zone: 'cat', index: 'remember' },
down: { zone: 'pager', index: 'remember' }
},
// 撞边界时的业务处理(优先级高于 focusGuide)
onBoundary: {
down: (page, index) => { /* ... */ return false }
}
})
各 layout 的导航规则
| layout | 响应的方向键 | 规则 |
|---|---|---|
vertical |
上 / 下 | index ± 1,目标不存在则视为撞边界 |
horizontal |
左 / 右 | index ± 1,同上 |
grid |
上下左右 | 按 columns 换算行列;左右不跨行(col 越界即边界) |
grid向下时若目标格子为空(最后一行不满),会自动钳制到该行最后一个有效元素。例如 13 个元素、5 列,从 index 9 按下 → 目标 14 不存在 → 落到 12。
focusGuide 的 index 取值
| 值 | 含义 |
|---|---|
| 数字 | 固定跳到该 index |
'remember' |
跳到目标 Zone 最后一次停留过的元素(体验最好,推荐用于主区域互跳) |
| 省略 | 跳到目标 Zone 的最小 index |
'remember' 若无历史记录,或目标 index 的元素已被注销,会自动回退到最小 index,不会卡死。
onBoundary:把边界变成业务动作
onBoundary[dir] 优先于 focusGuide 执行,回调签名 (page, index),返回值决定后续行为:
| 返回值 | 行为 |
|---|---|
false |
放弃处理,继续走 focusGuide |
{ zone, index } |
立即聚焦到该位置 |
'pending' |
你已自行调用 setPendingFocus,管理器不动,等新元素注册后自动聚焦 |
Promise |
视为异步。resolve {zone,index} 则补聚焦;resolve false 则回退到 focusGuide;resolve 'pending' 同上 |
回调的第一个参数 page 是内部页面状态对象,可用于查询:page.zoneItems(Map<zoneId, Map<index, itemId>>)、page.lastIndex(Map<zoneId, index>)。典型用法是判断目标区域是否为空再决定去向:
onBoundary: {
down: (page) => {
const zi = page.zoneItems.get('mov')
if (!zi || zi.size === 0) {
// 网格是空的(如收藏夹为空),别往下跳,改去分类栏,避免焦点掉进虚空
const idx = page.lastIndex.has('cat') ? page.lastIndex.get('cat') : 0
return { zone: 'cat', index: idx }
}
return false // 网格有内容,交给 focusGuide 正常处理
}
}
组件 API
<zxz-focusable>
可聚焦包装组件。它本身不带任何视觉样式——只负责注册、接收焦点、抛事件。高亮长什么样由你在 slot 里自己画。
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
page-id |
String | ✅ | 所属页面标识 |
zone |
String | ✅ | 所属区域 id |
index |
Number | ✅ | 在区域内的序号(必须与视觉顺序一致) |
item-id |
String | Number | ✅ | 元素唯一标识(同页面内不可重复) |
auto-focus |
Boolean | 注册完成后立即抢占焦点。用于浮层/弹窗的关闭按钮 | |
wrap-style |
Object | 透传给根 view 的样式(如需撑满父级时) |
Events
| 事件 | 载荷 | 触发时机 |
|---|---|---|
@activate |
— | 遥控器确认键,或鼠标/触摸点击 |
@focus |
{ itemId } 对象 |
获得焦点 |
@blur |
itemId 字符串 |
失去焦点 |
@click |
— | 仅点击(activate 也会同时触发) |
⚠️
@focus给的是对象{ itemId },@blur给的是裸字符串。这个不对称是既有约定,接线时容易写错:onItemFocus({ itemId }) { this.focusedId = itemId } // 解构 onItemBlur(itemId) { ... } // 不解构
关于 :index 动态变化:组件内部 watch 了 index,变化时自动调 updateItemIndex 同步映射。所以像分页器页码开窗这种「组件被 Vue 复用、但 index 变了」的场景是安全的——mounted 不会重跑,但 index 会正确迁移。
<zxz-paginator>
开箱即用的分页器(上一页 / 页码 / 下一页 / 跳转输入框),内部元素全部可聚焦。总页数 > 7 时中间页自动折叠为 …。
Props
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
page-id |
String | ✅ | 所属页面标识 |
current-page |
Number | 当前页(1 起),默认 1 | |
total-pages |
Number | 总页数,默认 1 |
Events:@prev、@next、@goto(page)
⚠️ 它硬编码使用
zone="pager"。 你必须在页面里注册一个id: 'pager'的 Zone,否则分页器的焦点无处安放:ZoneFocusManager.registerZone(page, { id: 'pager', layout: 'horizontal', focusGuide: { up: { zone: 'mov', index: 'remember' } } })内部 index 分配为:
0= 上一页,1..N= 页码,N+1= 下一页,N+2= 跳转框。
@goto 可能抛出超过 total-pages 的页码(用户在跳转框里手输)。这是刻意设计——由父组件钳制到末页,语义与 prev/next 的边界钳制一致,避免静默忽略让用户以为没响应:
onPagerGoto(page) {
this.changePage(Math.min(Math.max(1, page), this.totalPages), 0)
}
跳转输入框内部已自行处理 setInputFocused,你不用管。
核心 API
按键监听
| 函数 | 说明 |
|---|---|
initKeyListener() |
注册 plus.key 全局监听。在 TV 页面 onLoad 调用 |
removeKeyListener() |
注销。在 onUnload 调用 |
setInputFocused(bool) |
true 时焦点管理器完全不拦截按键,交给输入框/输入法 |
setVideoFullscreen(bool) |
true 时按键转发给业务层(uni.$emit('video-keydown', keyCode)),返回键除外 |
按键映射
| keyCode | 键 | 行为 |
|---|---|---|
| 19 / 20 / 21 / 22 | 上 / 下 / 左 / 右 | moveFocus(dir) |
| 23 / 66 / 13 | 确认 / 回车 | activate() |
| 4 | 返回 | 不处理,交给系统(走页面 onBackPress) |
长按连发按 40ms 节流,首次按下立即响应(不做防抖,避免遥控器体感黏滞)。
ZoneFocusManager
单例,import { ZoneFocusManager } from '@/uni_modules/zxz-tv-kit'。
页面级
| 方法 | 说明 |
|---|---|
activatePage(pageId, defaultFocus?) |
激活页面并接管按键。已有焦点则恢复,否则用 defaultFocus(格式 { zone, index })。元素未就位时存为 pendingFocus,稍后自动补聚焦 |
deactivatePage(pageId) |
当前元素失焦,但保留 currentFocus 供下次 activatePage 恢复 |
clearCurrentFocus(pageId) |
失焦并置空 currentFocus,注册不动。用于「焦点要交给 pendingFocus 接管」的场景 |
clearPage(pageId) |
彻底清空该页面全部数据。页面销毁时必调 |
deactivatePagevsclearCurrentFocus:前者是「暂时离开,回来还在原位」,后者是「主动放空,让别人来接管」。若不放空,pendingFocus会被仍然有效的旧焦点挡住而不生效。
Zone / 元素
| 方法 | 说明 |
|---|---|
registerZone(pageId, config) |
注册区域(见上文) |
unregisterZone(pageId, zoneId) |
注销区域及其全部元素 |
registerItem(pageId, zoneId, index, itemId, ref) |
注册元素。zxz-focusable 自动调用,一般不用手写。ref 需提供 { setFocused(bool), activate() } |
removeItem(pageId, itemId) |
注销元素。组件 beforeUnmount 自动调用 |
updateItemIndex(pageId, itemId, newIndex) |
迁移元素 index。组件 watch index 自动调用 |
clearZoneItems(pageId, zoneId) |
清空某 Zone 全部元素。翻页全量重建列表前必调(见下方配方) |
焦点控制
| 方法 | 说明 |
|---|---|
setFocus(pageId, zoneId, index) |
立即聚焦,返回是否成功(元素不存在返回 false) |
setPendingFocus(pageId, { zone, index }) |
设置待聚焦目标:元素还没渲染出来时先挂着,注册到位后自动生效 |
getCurrentFocus() |
返回当前页焦点 { zoneId, index } 或 null。注意字段是 zoneId |
moveFocus(direction) |
手动移动,'up'\|'down'\|'left'\|'right' |
activate() |
手动激活当前焦点元素 |
destroy() |
解绑全局事件监听(单测 teardown 用) |
也支持全局事件触发:uni.$emit('focus-move', 'down') / uni.$emit('focus-activate')。
布局缩放
设计稿基准 960×540,默认按宽度缩放(TV 恒为横屏)。1920 宽的电视上 scale = 2.0。
| 函数 | 说明 |
|---|---|
px(n, options?) |
设计稿 px → 实际 px,返回数字(已四舍五入避免半像素模糊) |
createStyles(obj) |
批量转换样式对象,数字 → 'Npx' 字符串,支持嵌套;字符串/数组原样保留 |
clearPxCache() |
清除缓存。屏幕方向/尺寸变化后调用 |
TV_DESIGN |
{ width: 960, height: 540 } |
getScale(strategy?) |
直接取缩放系数(不带缓存) |
px(20) // → 40(1920 宽屏)
px(14, { min: 24 }) // 最小值保护,防止字体在小屏上糊成一团
px(100, { strategy: 'height' }) // 按高度缩放
px(300, { max: 500 }) // 上限
createStyles({
box: { width: 140, height: 190, borderRadius: 8 }, // 数字 → 缩放 + 'px'
text: { fontSize: 16, color: '#fff' } // 字符串原样保留
})
// → { box: { width: '280px', height: '380px', borderRadius: '16px' },
// text: { fontSize: '32px', color: '#fff' } }
createStyles不支持min/max。需要保护值时先用px(n, {min})单独算好再填进去。
常见场景配方
焦点跟随滚动
给 Zone 补上 origin / itemSize / gap / scroll 四项,焦点移出可视区时管理器会自动改写你的 scrollTop:
ZoneFocusManager.registerZone(page, {
id: 'mov', layout: 'grid', columns: 5,
origin: { x: 0, y: 14 }, itemSize: { w: 140, h: 190 }, gap: { x: 0, y: 0 },
scroll: {
getter: () => this.movieScrollTop,
setter: (v) => { this.movieScrollTop = v },
viewport: () => Math.max(0, this.listHeight - this.pagerHeight)
}
})
<scroll-view scroll-y scroll-with-animation :scroll-top="movieScrollTop">
viewport建议返回你自己算出来的高度,不要用dom.getComponentRect量原生scroll-view——部分固件返回的是contentSize而非viewportSize,会导致滚动目标算得过小,焦点卡片整体落在可视区外。
翻页时的焦点接管
翻页会把整批元素销毁重建,index 复用,焦点极易卡死或错位。固定套路:
goToPage(uiPage, focusIndex = 0) {
// ① 主动清空旧注册 —— 不要依赖组件 beforeUnmount 的时序
ZoneFocusManager.clearZoneItems(this.currentRoute, 'mov')
this.movieListKey++ // ② 换 key 强制整批重建
this.movies = newList
this.resetMovieScroll()
// ③ 挂上待聚焦目标,新元素注册后自动生效
ZoneFocusManager.setPendingFocus(this.currentRoute, {
zone: 'mov', index: Math.min(focusIndex, newList.length - 1)
})
return 'pending' // ④ 告诉 onBoundary:我接管了,你别动
}
为什么必须 clearZoneItems:nvue 下旧组件的销毁可能延迟。不主动清的话,currentFocus 会长期停留在旧 index 上,pendingFocus 判定「当前焦点仍有效」就不会接管,焦点卡在旧位置反复触发翻页。
输入框(搜索 / 跳转页码)
输入框聚焦时软键盘弹起,此时必须让出按键,否则确认键会被焦点管理器抢走:
onSearchActivate() { this.searchInputFocused = true }, // 确认键 → 进入输入态
onSearchInputFocus() { setInputFocused(true) }, // 让出按键
onSearchInputBlur() { setInputFocused(false) }, // 收回按键
searchVideos() {
// 确认搜索:主动退出输入态,别依赖 @blur
this.searchInputFocused = false
setInputFocused(false)
this.loadMovies({ isSearch: true })
}
<!-- v-if 重建 input + :focus="true" 创建即聚焦 -->
<input v-if="searchInputFocused" :focus="true" v-model="keyword"
@focus="onSearchInputFocus" @blur="onSearchInputBlur" @confirm="searchVideos" />
两个关键点:① 不要常驻
<input>,非输入态时用<text>占位,否则它会一直抢键。② 不要用ref.focus(),TV nvue 下不可靠;用v-if重建 +:focus="true"。最容易踩的坑是
setInputFocused(true)后忘了置回false——此时焦点管理器永久静默,整个遥控器像死机一样。任何退出输入态的路径都要显式setInputFocused(false)。
全屏视频播放
全屏时方向键应该控制快进/暂停,而不是移动焦点:
// 进入全屏
setVideoFullscreen(true)
uni.$on('video-keydown', this.onVideoKey)
onVideoKey(keyCode) {
switch (keyCode) {
case 23: case 66: case 13: this.togglePlay(); break
case 21: this.requestSeek(-this.seekStep()); break
case 22: this.requestSeek(this.seekStep()); break
}
}
// 退出全屏 / 组件销毁
setVideoFullscreen(false)
uni.$off('video-keydown', this.onVideoKey)
返回键(keyCode 4)不会被转发,始终交给系统,用于退出全屏。
快进键必须做合并,不能一次按键一次
seek()。 遥控连点/长按会在几百毫秒内产生多次按键,逐次下发 seek 会让原生播放器(ExoPlayer / HLS)反复中断重缓冲、seek 相互抢占,最终不再回调任何事件——表现是进度条不动、loading 圈常驻的"卡死"。正确做法:按键只累加目标位置并即时更新 UI,停手 ~350ms 后才下发一次seek()。配套的两个坑:① 累加基准要用「上一次的目标位置」,不能用
@timeupdate写入的当前进度——seek 未落地前它还是旧值,第二次按键会把目标打回原点。② seek 待定期间要忽略@timeupdate回调,并配一个看门狗超时兜底,否则待定态卡住会让进度条永远不再跟随。
弹窗 / 浮层抢焦点
给关闭按钮加 auto-focus,注册完成即接管焦点:
<zxz-focusable v-if="dialogVisible" item-id="dlg-close" zone="dialog"
:index="0" :page-id="pageId" auto-focus @activate="close">
<text>关闭</text>
</zxz-focusable>
浮层关闭后,用 setFocus 把焦点还给原位置。
踩坑与注意事项
| 坑 | 后果 | 解法 |
|---|---|---|
index 与视觉顺序不一致 |
导航跳到肉眼不相邻的元素 | 用 v-for 的下标;有条件渲染(如分组标题)时单独算一份连续的 focusIndex,别直接用数组下标 |
忘记 clearPage |
页面重进后旧元素残留,焦点跳到已销毁元素 | onUnload 必调 |
setInputFocused(true) 后没置回 |
遥控器完全失灵 | 所有退出输入态的路径都要 setInputFocused(false) |
用 ref.focus() 聚焦 input |
TV nvue 下不生效 | v-if 重建 + :focus="true" |
常驻 <input> |
持续抢键,方向键失效 | 非输入态用 <text> 占位 |
翻页不调 clearZoneItems |
焦点卡在旧 index,反复触发翻页 | 见上方翻页配方 |
用了 zxz-paginator 但没注册 pager Zone |
分页器无法聚焦 | 注册 id: 'pager' 的 horizontal Zone |
origin/itemSize 填了实际 px |
滚动位置在不同分辨率上错乱 | 填设计稿原值,管理器内部会 px() |
在 mounted 同步调 activatePage |
元素尚未注册,默认焦点落空 | 放进 $nextTick(或依赖 pendingFocus 自动补) |
@blur 当对象解构 |
高亮不消失 | @focus 给对象、@blur 给字符串 |
nvue 样式补充(本 kit 组件内部遵循的约定):
border走 inline style,不要写在 scss 里——nvue 对 border 简写/独立属性的解析行为不稳定。- 聚焦态用「透明 border 占位 + 聚焦时换实色」,避免按钮尺寸跳动 2px。
- nvue 不支持 border-color 过渡,
transition只对background-color之类有效。 - 不要显式写
overflow: visible(nvue 默认即是),会触发[plugin:vite:nvue-css]编译错误。
FAQ
Q:能在普通 .vue 页面用吗?
不能。本 kit 是 nvue 专用,且按键依赖 plus.key(仅 App 端)。
Q:一个页面里能有几个 Zone? 不限。首页 5 个(search / fav / cat / mov / pager)是典型规模。
Q:Zone 之间怎么才能互相到达?
靠 focusGuide。它是单向的——mov 声明了 left → cat,不代表 cat 能靠右键回到 mov,两边都要各自声明。
Q:焦点为什么不动?
按顺序排查:① initKeyListener() 调了吗?② activatePage() 调了吗、getCurrentFocus() 返回 null 吗?③ 是不是 setInputFocused(true) 没置回?④ 目标 index 的元素真的注册了吗(page.zoneItems)?
Q:撞到边界有反馈吗?
有,会触发 uni.vibrateShort() 震动(App 端支持时)。
Q:数据是异步加载的,要不要等接口回来再设焦点?
不用。直接 activatePage(pageId, { zone, index }),元素没就位时会存为 pendingFocus,注册到位后自动补聚焦。
Q:getCurrentFocus() 返回的字段是 zone 还是 zoneId?
是 zoneId。但传入 activatePage / setPendingFocus 的参数字段是 zone。这两处不一致,注意别写混。
许可与免责
软件无担保
本软件按 「原样」(AS IS) 提供,不附带任何形式的明示或暗示担保,包括但不限于:适销性担保、特定用途适用性担保、不侵权担保。在任何情况下,作者或版权持有人均不对因本软件或本软件的使用、其它处置而产生的任何索赔、损害或其它责任负责(无论合同、侵权或其它形式的诉讼依据为何)。
第三方视频源免责声明
本 kit 是纯技术框架,仅提供 TV 端焦点导航与布局缩放能力,不接入、不存储、不分发任何视频内容。使用者自行决定接入的第三方视频源(含但不限于采集站、CDN、图床、播放器后端)的内容、合规性、可用性、版权归属及变更风险,均由使用者独立承担。
具体包括但不限于:
- 第三方源站接口变更、失效、数据漂移导致的播放失败或内容错乱
- 第三方源站所提供内容的版权、合法性、广告、违规信息
- 第三方图床 / CDN 的可用性、隐私策略、数据安全
- 接入第三方服务引发的合规、备案、内容审查风险
作者不为上述任何情形承担任何责任,亦不对使用本 kit 开发的最终产品做任何背书。
使用范围与责任
- 本 kit 仅供学习研究、内部技术评估及合法业务场景使用
- 禁止将本 kit 或基于本 kit 修改的版本用于任何违法违规用途,包括但不限于:传播侵权内容、绕过平台版权保护、违反所在地区法律法规的行为
- 禁止将本 kit 未经授权用于商业转售、SaaS 化再授权或二次商业包装
- 因使用、修改、分发本 kit 所产生的一切合规、安全、版权、商业纠纷及法律责任,均由使用者自行承担
- 作者保留在发现违规用途时公开声明、撤销授权、停止维护的权利
⚠️ 在中国大陆地区使用 TV 类应用涉及《互联网视听节目服务管理规定》《广播电视管理条例》等多项法规,使用者须自行完成相应资质备案与内容审核,并确保接入内容符合所在司法辖区的法律要求。本 kit 不提供任何合规咨询。

收藏人数:
购买源码授权版(
试用
赞赏(0)
下载 17376
赞赏 109
下载 12495236
赞赏 1939
赞赏
京公网安备:11010802035340号