npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@qynpm/hooks

v1.0.3

Published

起印公共 hooks 包。

Readme

@qynpm/hooks

@qiyin 公共 hooks 的 npm 拆包目录。

录单拆包的 transform 实际代码盘点见 录单拆包迁移总览。该文档记录了 packages/erp/src/views/transform 当前缺少的公共 UI、hooks、pickers、product-preview 和 table-schema 能力。

当前状态

目前这个包还在迁移初期,不是把老的 @qiyin/hooks 一次性整体搬过来,而是按“先公共、再解耦、最后替换”的方式逐步迁移。

当前已经迁入的能力:

  • useCreateInstance
  • useInstanceView
  • useKey
  • useExpire
  • createUseConfig
  • createUseRefresh
  • useBottomBar
  • feedback runtime

当前职责

这个包主要承接适合做成 npm 公共能力的 hooks。

当前这批能力的边界是:

  • useCreateInstance
    • 负责动态创建实例
    • 支持普通挂载
    • 支持 asComponent 模式
  • useInstanceView
    • 负责渲染 useCreateInstance 创建出来的实例视图
  • useKey
    • 负责提供组件刷新 key 和递增方法
    • 不依赖业务 store、router、request
  • useExpire
    • 负责通过 HEAD 请求检查入口文件 Last-Modified
    • 支持传入 system 保持旧调用兼容,也支持传入完整 url / fetcher
  • createUseConfig
    • 负责生成可注入的配置读取 hook
    • 只承接 loading / error / configs / mapping / getConfig 这些公共逻辑
    • API、缓存和响应结构由宿主项目注入

录单 hooks 迁移清单

录单基础组件拆分基线 中提到的 hooks 处理状态如下:

| hook | 当前状态 | 处理建议 | | --- | --- | --- | | useKey | 已迁入 @qynpm/hooks | 可作为公共 hook 使用 | | useExpire | 已迁入 @qynpm/hooks | 保留旧 useExpire(system) 调用,同时支持对象参数 | | useCreateInstance | 已迁入 @qynpm/hooks | 已被 @qynpm/form@qynpm/product-preview 使用 | | useInstanceView | 已迁入 @qynpm/hooks | 宿主应用按需接入实例视图 | | useConfig | 已提供公共工厂 createUseConfig | 各项目继续保留薄包装,注入本项目 API 和缓存 | | useRefresh | 已提供公共工厂 createUseRefresh | ERP 已补本地 adapter,页面 import 可保持不变 | | useBottomBar | 已提供宿主协议 useBottomBar | ERP 平张录单已做最小宿主验证,正式接入仍建议走项目 adapter | | useCol | 暂不进入 @qynpm/hooks | 详见 @qynpm/table-schema,建议单独做 table/schema 包 | | usePopperMessage | 已收敛为 feedback.popperMessage 协议 | ERP 已接 setupPopperMessageRuntime() 宿主实现,旧 hook 不原样迁 |

当前原则:

  • 纯公共、无宿主依赖的 hook 先迁入。
  • 依赖 store、router、request、页面容器的 hook 先做 adapter 方案,不原样搬迁。
  • 只服务录单流程的 hook 暂不进入公共 hooks 包。

createUseConfig

createUseConfig 用来收口各项目重复的配置读取逻辑,但不绑定任何项目的 API、store 或 alias。

推荐使用方式:

import { createUseConfig } from '@qynpm/hooks'
import { GlobalAPI } from '@api/index'
import useStateStore from '@/store/modules/state'

export default createUseConfig({
  fetchConfig: GlobalAPI.getConfig,
  getCache(id) {
    return useStateStore().config[id]
  },
  setCache(id, value) {
    useStateStore().setConfig(id, value)
  }
})

如果宿主接口返回结构不是 { data: GlobalConfig[] },可以通过 normalizeResponse 适配:

export default createUseConfig({
  fetchConfig: GlobalAPI.getConfig,
  normalizeResponse(response) {
    return response.rows
  }
})

生成后的 hook 保持旧调用形态:

const useConfig = createUseConfig({ fetchConfig })
const { mapping, configs, loading, error, getConfig } = useConfig(2)
await getConfig()

边界:

  • @qynpm/hooks 不直接 import GlobalAPI
  • @qynpm/hooks 不直接 import Pinia store。
  • 各项目保留自己的 src/hooks/useConfig.ts 作为 adapter。

后续组件迁移方案

公共 hooks 包只提供可复用内核,不直接替换各项目页面。后续迁移按“公共包能力 -> 宿主 adapter -> 组件引用”的顺序推进。

迁移分层

  1. @qynpm/hooks

    • 放纯公共逻辑。
    • 不依赖 @/store@apivue-router 页面实例或具体业务模块。
    • 对需要宿主能力的 hook,只提供工厂函数或运行时协议。
  2. 宿主项目 src/hooks

    • 保留薄包装。
    • 负责注入本项目 API、store、缓存、router、页面容器等能力。
    • 对旧页面继续暴露原来的 hook 名称,降低替换成本。
  3. 业务组件 / 页面

    • 优先从宿主 src/hooks 引入。
    • 新拆出来准备发布 npm 的组件,才直接从 @qynpm/hooks 引入纯公共 hook。

useConfig 迁移方式

第一阶段只改各项目 src/hooks/useConfig.ts,不批量改页面调用。

旧页面保持:

import useConfig from '@/hooks/useConfig'

宿主 adapter 改成:

import { createUseConfig } from '@qynpm/hooks'
import { GlobalAPI } from '@api/index'
import useStateStore from '@/store/modules/state'

export default createUseConfig({
  fetchConfig: GlobalAPI.getConfig,
  getCache(id) {
    return useStateStore().config[id]
  },
  setCache(id, value) {
    useStateStore().setConfig(id, value)
  }
})

如果项目没有统一缓存,可以只注入接口:

import { createUseConfig } from '@qynpm/hooks'
import { AppAPI } from '@api/index'

export default createUseConfig({
  fetchConfig: AppAPI.getConfig
})

如果接口入参不是 { num },在 adapter 内转换:

export default createUseConfig({
  fetchConfig({ num }) {
    return GlobalAPI.getConfig(num)
  }
})

如果接口返回结构不同,用 normalizeResponse 转换:

export default createUseConfig({
  fetchConfig: GlobalAPI.getConfig,
  normalizeResponse(response) {
    return response.data?.list || response.rows || []
  }
})

各项目建议迁移顺序

先迁已经是薄包装的项目:

  1. packages/erp/src/hooks/useConfig.ts
  2. packages/foundation/src/hooks/useConfig.ts
  3. packages/production/src/hooks/useConfig.ts
  4. packages/quotation/src/hooks/useConfig.ts

再迁有自定义实现的项目:

  1. packages/app/src/hooks/useConfig.ts
  2. packages/market/src/hooks/useConfig.ts
  3. packages/resource/src/hooks/useConfig.ts
  4. packages/repository/src/hooks/useConfig.ts
  5. packages/quotate-page/src/hooks/useConfig.ts

迁移自定义实现时,先保持页面调用和返回字段不变,只替换内部实现。

useKey / useExpire 迁移方式

这两个属于低耦合公共 hook,可以在新组件中直接使用:

import { useKey, useExpire } from '@qynpm/hooks'

旧页面不需要批量替换。后续如果某个包要发布 npm,且内部用到了旧 @qiyin/hooks/useKey@qiyin/hooks/useExpire,再局部改成 @qynpm/hooks

暂不直接迁移的 hooks

useRefresh

  • 旧实现依赖 vue-routertagsView store 和 $tab
  • 已提供 createUseRefresh({ getRoute, getVisitedViews, refreshPage, delIframeView })
  • ERP 侧通过 packages/erp/src/hooks/useRefresh.ts 接本地 adapter,页面 import 不变。

useBottomBar

  • 旧实现持有全局底部栏状态和 DOM 高度计算。
  • 已提供宿主协议和 getBottomBarEntries()
  • ERP 平张录单已挂最小 BottomBarHost 验证,正式接入仍建议按宿主 layout 单独设计。

useCol

  • 依赖 vxe/table 渲染组件、配置弹窗、列配置 schema。
  • 更适合单独做 table/schema 方案,不混入普通 hooks 迁移。
  • 详细方案见 @qynpm/table-schema

table-schema 后续替代旧 useCol 时,以下能力应由 hooks runtime 或宿主 adapter 提供,不应反向写入 table-schema 内核:

  • confirm:菜单危险操作确认,可复用 feedback runtime 的 confirm
  • message / popperMessage:复制、菜单操作提示,可复用 feedback runtime。
  • hasPermission:菜单权限过滤,由宿主注入权限实现。
  • 用户缓存 adapter:旧 useUserCache 只能在 ERP adapter 中转接,不进入公共 table-schema 内核。
  • copy:复制实现由宿主注入,浏览器项目可用 navigator.clipboard,ERP 可接旧 @qiyin/utils/copy

hooks 包只维护通用 runtime 协议和组合式函数,不承接 Element Plus 表格列渲染、列设置 UI 或业务列类型。

usePopperMessage

  • 旧实现依赖鼠标全局监听、TSX、样式和 useCreateInstance
  • 已收敛成统一 feedback runtime 的 popperMessage(target, message, options)
  • ERP 已通过 setupPopperMessageRuntime() 注入宿主 DOM 浮层实现,避免重复注册鼠标监听。

useRefresh 宿主协议

useRefresh 用来刷新当前路由对应的标签页视图。旧实现很小,但直接依赖 vue-router、宿主 tagsView store 和全局 $tab,不能原样放进公共 hooks 包。

当前建议拆成两层:

  1. @qynpm/hooks

    • 提供 createUseRefresh 工厂。
    • 不直接 import vue-router
    • 不直接 import @/store/modules/tagsView
    • 不读取组件实例上的 $tab
    • 只负责“找当前 view -> 调宿主刷新 -> iframe/link 额外清理”的流程编排。
  2. 宿主项目 src/hooks/useRefresh.ts

    • 注入当前项目的 routevisitedViewsrefreshPagedelIframeView
    • 继续向业务页面暴露旧的 useRefresh() 用法。
    • ERP、app、market 等不同后台项目可以各自接自己的 tagsView/store。

目标

让公共包复用刷新逻辑,但不绑定后台 layout。

典型场景:

  • 保存成功后刷新当前标签页。
  • 删除数据后刷新当前列表页。
  • iframe/link 类型页面刷新时,同时清理宿主 iframe 缓存。
  • 不同后台项目使用同一套刷新协议,但各自注入自己的路由和标签页实现。

建议 API

第一版建议实现:

createUseRefresh(options)

协议:

type RefreshRoute = {
  path: string
  fullPath?: string
  name?: string | symbol | null
  meta?: {
    link?: string
    [key: string]: unknown
  }
  [key: string]: unknown
}

type RefreshView = {
  path: string
  fullPath?: string
  name?: string | symbol | null
  [key: string]: unknown
}

type CreateUseRefreshOptions = {
  getRoute: () => RefreshRoute
  getVisitedViews: () => RefreshView[]
  refreshPage: (view: RefreshView) => void | Promise<void>
  delIframeView?: (route: RefreshRoute) => void | Promise<void>
  matchView?: (view: RefreshView, route: RefreshRoute) => boolean
  onMissingView?: (route: RefreshRoute) => void
}

返回:

type UseRefreshReturn = () => Promise<void>

默认匹配规则:

view.path === route.path

如果某个宿主使用 fullPathname 管理标签页,可以通过 matchView 覆盖。

宿主接入方式

ERP 这类后台项目保留本地薄包装,页面不需要改 import。

src/hooks/useRefresh.ts

import { useRoute } from 'vue-router'
import useTagsViewStore from '@/store/modules/tagsView'
import { getCurrentInstance } from 'vue'
import { createUseRefresh } from '@qynpm/hooks'

export default function useRefresh() {
  const route = useRoute()
  const tagsViewStore = useTagsViewStore()
  const { proxy } = getCurrentInstance()!

  return createUseRefresh({
    getRoute: () => route,
    getVisitedViews: () => tagsViewStore.visitedViews,
    refreshPage(view) {
      proxy.$tab.refreshPage(view)
    },
    delIframeView(route) {
      tagsViewStore.delIframeView(route)
    }
  })()
}

如果想减少每次调用时重新创建工厂,也可以在宿主 hook 内先创建 refresh 再返回:

export default function useRefresh() {
  const route = useRoute()
  const tagsViewStore = useTagsViewStore()
  const { proxy } = getCurrentInstance()!

  const refresh = createUseRefresh({
    getRoute: () => route,
    getVisitedViews: () => tagsViewStore.visitedViews,
    refreshPage: (view) => proxy.$tab.refreshPage(view),
    delIframeView: (route) => tagsViewStore.delIframeView(route)
  })

  return refresh
}

页面使用方式

旧页面继续这样用:

import useRefresh from '@/hooks/useRefresh'

const refreshView = useRefresh()

async function handleSave() {
  await saveData()
  await refreshView()
}

准备独立发布的公共组件,如果确实需要刷新宿主页面,不应该直接 import ERP 的 @/hooks/useRefresh。应由宿主通过 props、事件或 adapter 注入刷新能力。

生命周期约定

  • 公共包不要求必须在组件 setup 内调用,但宿主 adapter 如果使用 useRoute()getCurrentInstance(),仍然必须放在组件 setup 上下文。
  • 找不到当前 view 时默认静默返回,避免误刷新其他标签页。
  • route.meta.link 存在时,刷新后调用 delIframeView(route)
  • refreshPagedelIframeView 都允许返回 Promise,方便宿主串接异步 store action。

当前不做

  • 不在 @qynpm/hooks 里直接 import vue-router
  • 不在 @qynpm/hooks 里直接 import 任意项目的 tagsView store。
  • 不在 @qynpm/hooks 里读取 proxy.$tab
  • 不替业务页面批量改 import。
  • 不处理多标签页关闭、固定标签、右键菜单等 TagsView 行为,这些仍归宿主 layout/store 管。

ERP 验证建议

第一阶段只改 ERP 的本地 adapter:

packages/erp/src/hooks/useRefresh.ts

验证点:

  • 页面原有 import useRefresh from '@/hooks/useRefresh' 不变。
  • 普通页面调用 refreshView() 后,当前标签页重新加载。
  • route.meta.link 页面调用后,会触发 tagsViewStore.delIframeView(route)
  • 当前路由不在 visitedViews 时,不抛错、不刷新其他页面。
  • 不把 vue-routertagsView$tab 引入 @qynpm/hooks

useBottomBar 宿主协议

useBottomBar 第一版已按宿主协议迁入 @qynpm/hooks。旧实现同时承担了全局状态、内容渲染、高度计算、页面生命周期清理和 DOM 查询,不能原样搬进公共包。

当前拆成两层:

  1. @qynpm/ui

    • QyActionBar 继续只负责底部操作栏的 UI 布局。
    • 提供 sticky、左右分区、窄屏换行、底部安全区适配等展示能力。
    • 不负责跨页面注册、路由清理或业务按钮编排。
  2. @qynpm/hooks

    • 只提供底部栏运行时协议。
    • 不直接查询 .bottom-bar-content
    • 不直接依赖 vue-router
    • 不直接 import 宿主 store 或页面布局组件。

目标

底部栏 runtime 负责让页面或业务组件把操作区挂到宿主统一容器里,并由宿主决定这个容器如何展示。

典型场景:

  • 录单页面底部提交 / 保存 / 取消按钮。
  • 详情页底部审核 / 驳回 / 打印按钮。
  • 页面切换后自动清理当前页面注册的底部操作区。
  • 移动端或窄屏下使用 QyActionBar 的换行和安全区样式。

建议 API

当前已实现:

configureBottomBarRuntime(runtime)
getBottomBarRuntime()
getBottomBarEntries()
useBottomBar(render, options)
useBottomBarState(id)

协议:

type BottomBarRender = () => VNode | null

type BottomBarRegisterOptions = {
  id: string
  routeKey?: string
  destroyOnUnmount?: boolean
  destroyOnRouteChange?: boolean
  minHeight?: number
  maxHeight?: number
}

type BottomBarEntry = {
  id: string
  routeKey?: string
  content: VNode | null
  visible: boolean
  height?: number
  minHeight?: number
  maxHeight?: number
}

type BottomBarRuntime = {
  register: (entry: BottomBarEntry) => void
  update: (id: string, entry: Partial<BottomBarEntry>) => void
  unregister: (id: string) => void
  show: (id: string) => void
  hide: (id: string) => void
  setHeight?: (id: string, height: number) => void
}

宿主接入方式

宿主应用提供一个统一底部栏容器,并在初始化阶段注册 runtime。这个容器不要求宿主必须有登录页、左侧菜单或后台 layout。

独立项目最小接入

适用于页面数量不多、只有 App.vue + router-view 的新项目。公共包内部已经维护 getBottomBarEntries(),所以最小宿主可以先注册一组无状态 runtime,让页面注册流程闭环。

src/runtime/setupBottomBarRuntime.ts

import { configureBottomBarRuntime } from '@qynpm/hooks'

configureBottomBarRuntime({
  register() {},
  update() {},
  unregister() {},
  show() {},
  hide() {},
  setHeight() {}
})

src/components/BottomBarHost.vue

<template>
  <div v-if="activeEntry?.visible" class="bottom-bar-host">
    <QyActionBar bordered shadow justify="end">
      <component :is="activeEntry.content" />
    </QyActionBar>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { getBottomBarEntries } from '@qynpm/hooks'
import { QyActionBar } from '@qynpm/ui'

const entries = getBottomBarEntries()

const activeEntry = computed(() => {
  const visibleEntries = Array.from(entries.value.values()).filter(
    (entry) => entry.visible && entry.content
  )

  return visibleEntries.at(-1) ?? null
})
</script>

<style scoped>
.bottom-bar-host {
  position: fixed;
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 50;
}
</style>

src/main.ts

import './runtime/setupBottomBarRuntime'

App.vue

<template>
  <router-view />
  <BottomBarHost />
</template>

<script setup lang="ts">
import BottomBarHost from '@/components/BottomBarHost.vue'
</script>

如果新项目没有统一 layout,也可以把 BottomBarHost 放在某个录单页面根组件里。区别是:放在 App.vue 时跨页面统一承载,放在页面根组件时只服务当前业务域。

页面局部挂载时,不建议继续使用 fixed + left/right 0,否则会脱离当前页面宽度。例如录单页面自身有 max-width 或在后台主内容区内居中展示时,底部栏会铺满宿主主区域。

局部页面推荐写法:

.bottom-bar-host {
  position: sticky;
  bottom: 40px;
  z-index: 50;
  width: 100%;
  background: #fff;
}

其中 bottom 按页面已有底部操作区高度调整;如果新项目没有旧底栏,可以直接用 bottom: 0

复杂宿主接入

如果宿主已有 Pinia store、后台 layout、路由缓存或多标签页,需要把 runtime 转接到宿主自己的状态管理中:

import { configureBottomBarRuntime } from '@qynpm/hooks'
import { bottomBarStore } from '@/stores/bottomBar'

configureBottomBarRuntime({
  register(entry) {
    bottomBarStore.register(entry)
  },
  update(id, entry) {
    bottomBarStore.update(id, entry)
  },
  unregister(id) {
    bottomBarStore.unregister(id)
  },
  show(id) {
    bottomBarStore.show(id)
  },
  hide(id) {
    bottomBarStore.hide(id)
  },
  setHeight(id, height) {
    bottomBarStore.setHeight(id, height)
  }
})

页面使用方式

页面侧只注册内容,不关心容器放在哪里:

import { useBottomBar } from '@qynpm/hooks'

useBottomBar(
  () => (
    <>
      <el-button>取消</el-button>
      <el-button type="primary">提交</el-button>
    </>
  ),
  {
    id: 'create-order-bottom-bar',
    destroyOnUnmount: true,
    destroyOnRouteChange: true
  }
)

生命周期约定

  • id 必填,公共包不再递归查找组件名作为隐式 id。
  • 页面卸载时,如果 destroyOnUnmount 不是 false,调用 runtime.unregister(id)
  • 路由变化清理由宿主 adapter 判断,公共包只接收 routeKey 或显式清理调用。
  • 内容更新只调用 runtime.update(id, { content }),不在 hook 内查询 DOM 高度。
  • 高度计算优先由宿主容器或 QyActionBar 自身完成,hook 只保留 setHeight 协议。

当前不做

  • 不把旧 useBottomBar 原样搬进 @qynpm/hooks
  • 不在公共包内监听 window.resize 并直接写全局状态。
  • 不在公共包内查询 .bottom-bar-content 或任何宿主 DOM class。
  • 不在公共包内直接依赖 vue-router
  • 不把底部按钮业务逻辑写进 QyActionBar

当前验证

@qynpm/hooks 已补充 bottomBar runtime 单测,覆盖:

  • 缺少 id 时抛出明确错误。
  • 缺少宿主 runtime 时抛出明确错误。
  • configureBottomBarRuntime 只接受函数或 null
  • useBottomBar 能注册内容并随响应式依赖更新。
  • 组件卸载时按 destroyOnUnmount 清理或保留。
  • show / hide / setHeight / unregister 会调用宿主 runtime。
  • useBottomBarState 能读取并操作已有条目。

与 QyActionBar 的关系

QyActionBar 是 UI 壳,解决“怎么展示底部操作区”。

useBottomBar runtime 是状态和注册协议,解决“谁把内容放进宿主底部容器”。

两者可以配合使用,但不互相强依赖。普通页面也可以直接使用 QyActionBar,不一定要接 useBottomBar runtime。

ERP 当前联调示例

ERP 已在平张录单页面做过最小联调:

packages/erp/src/main.js
packages/erp/src/views/transform/index2.vue
packages/erp/src/views/transform/components/BottomBarHost.vue

当前验证方式:

  • main.js 注册 no-op configureBottomBarRuntime,只用于打通公共协议。
  • index2.vue 使用 useBottomBar 注册 bottomBar测试1 / 保存测试1 / 提交测试1
  • BottomBarHost.vue 读取 getBottomBarEntries(),并使用 QyActionBar 展示最后一个可见 entry。
  • 平张页已有旧 sticky 操作栏,测试宿主使用 position: sticky; width: 100%,避免脱离 max-w-[640px] 页面容器。
  • 旧操作栏暂时保留,新协议接入只用于验证展示、点击和卸载流程。

验证要求

每迁一个宿主 adapter,至少确认:

  1. 页面原有 import 不变。
  2. mapping / configs / loading / error / getConfig 返回字段不变。
  3. 已有缓存命中逻辑不变。
  4. 接口入参和返回结构经过 adapter 转换后和旧行为一致。
  5. 不把宿主 API、store、router 引入 @qynpm/hooks

统一反馈 Runtime

统一反馈 runtime 用来收口组件和业务薄封装里的提示、确认、通知和局部浮层提示能力。

目标是让公共包不直接绑定 ElMessage / ElMessageBox / ElNotification,也不在每个组件里重复写一套反馈实现。

放置位置

源码位置:

@qiyin/hooks/src/feedback

发布到:

@qynpm/hooks

不建议放到:

  • @qynpm/ui:反馈 runtime 不是 UI 组件。
  • @qynpm/form:反馈能力不只服务表单。
  • @qynpm/product-preview:商品包当前有 feedback 注入,但后续应向统一 runtime 收口。

API 形态

当前对外提供:

configureFeedbackRuntime(runtime)
getFeedbackRuntime()
useFeedback()
message(options)
confirm(options)
notify(options)
popperMessage(target, message, options)

当前实现状态:

  • 已实现 configureFeedbackRuntime
  • 已实现 getFeedbackRuntime
  • 已实现 useFeedback
  • 已实现 message / confirm / notify
  • 已实现 popperMessage 协议和导出方法
  • ERP 已接入 setupPopperMessageRuntime(),宿主未注入时仍会抛出明确错误

协议:

type FeedbackMessageOptions = {
  message: string
  type?: 'success' | 'warning' | 'info' | 'error'
  duration?: number
}

type FeedbackConfirmOptions = {
  title?: string
  message: string
  confirmButtonText?: string
  cancelButtonText?: string
  type?: 'success' | 'warning' | 'info' | 'error'
}

type FeedbackNotifyOptions = FeedbackMessageOptions & {
  title?: string
}

type FeedbackPopperTarget =
  | HTMLElement
  | { x: number; y: number }
  | 'mouse'

宿主接入方式

宿主项目只需要在初始化层注册一次,推荐放在:

  • main.ts / main.js
  • setupFeedbackRuntime.ts
  • 项目公共初始化入口

以 Element Plus 为例:

import { configureFeedbackRuntime } from '@qynpm/hooks'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'

configureFeedbackRuntime({
  message(options) {
    return ElMessage(options)
  },
  confirm(options) {
    return ElMessageBox.confirm(options.message, options.title, options)
  },
  notify(options) {
    return ElNotification(options)
  }
})

如果希望拆成独立初始化文件,可以这样写:

// setupFeedbackRuntime.ts
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'
import { configureFeedbackRuntime } from '@qynpm/hooks'

export function setupFeedbackRuntime() {
  configureFeedbackRuntime({
    message(options) {
      return ElMessage(options)
    },
    confirm(options) {
      return ElMessageBox.confirm(options.message, options.title, options)
    },
    notify(options) {
      return ElNotification(options)
    }
  })
}

然后在应用启动入口调用:

import { setupFeedbackRuntime } from './setupFeedbackRuntime'

setupFeedbackRuntime()

使用方式

公共包或新拆出来的业务薄封装中,优先从 @qynpm/hooks 调用反馈能力:

import { message, confirm, notify, useFeedback } from '@qynpm/hooks'

message({
  type: 'success',
  message: '保存成功'
})

await confirm({
  title: '提示',
  message: '确定删除当前数据吗?',
  confirmButtonText: '确定',
  cancelButtonText: '取消',
  type: 'warning'
})

notify({
  title: '处理完成',
  type: 'success',
  message: '数据已同步'
})

const feedback = useFeedback()
feedback.message({
  type: 'warning',
  message: '请先选择数据'
})

也可以读取当前 runtime,用于调试或桥接其它包:

import { getFeedbackRuntime } from '@qynpm/hooks'

const runtime = getFeedbackRuntime()

未注册行为

@qynpm/hooks 不内置 Element Plus fallback。宿主未注册对应能力时,调用会抛出明确错误:

@qynpm/hooks 缺少 feedback 运行时配置:请在应用启动前通过 configureFeedbackRuntime 注入 message

这样可以避免公共包在内部偷偷绑定某个 UI 框架,也能更早暴露宿主漏接 runtime 的问题。

popperMessage

popperMessage 用来承接旧 usePopperMessage 的“鼠标 / 元素 / 坐标附近轻量提示”能力。公共包只暴露协议,不内置 DOM 浮层实现。

import { popperMessage } from '@qynpm/hooks'

popperMessage('mouse', '提示内容')

旧实现不适合原样迁入公共包,原因:

  • 依赖 window.addEventListener('mousemove') 全局监听。
  • 依赖 JSX、CSS、Tailwind class 和 useCreateInstance
  • 每次调用 usePopperMessage() 都会新增一次 mousemove 监听,旧代码没有清理。
  • 定位和动画都是宿主 UI 行为,不应该塞进 @qynpm/hooks

推荐方案是在宿主实现 setupPopperMessageRuntime(),启动时只执行一次:

// src/runtime/setupPopperMessageRuntime.ts
import { h, render } from 'vue'
import { configureFeedbackRuntime, type FeedbackPopperTarget } from '@qynpm/hooks'

type PopperMessageType = 'success' | 'warning' | 'error' | 'info'

type PopperMessageOptions = {
  duration?: number
  type?: PopperMessageType
}

let installed = false
let lastMousePosition = {
  x: 0,
  y: 0
}

function resolveTargetPosition(target: FeedbackPopperTarget) {
  if (target === 'mouse') return lastMousePosition

  if (target instanceof HTMLElement) {
    const rect = target.getBoundingClientRect()
    return {
      x: rect.x,
      y: rect.y
    }
  }

  return target
}

function showPopperMessage(
  target: FeedbackPopperTarget,
  message: string,
  options: PopperMessageOptions = {}
) {
  const { duration = 1500, type = 'success' } = options
  const position = resolveTargetPosition(target)
  const container = document.createElement('div')

  document.body.appendChild(container)

  const destroy = () => {
    render(null, container)
    container.remove()
  }

  render(
    h(
      'div',
      {
        class: ['qiyin-popper-message', `is-${type}`],
        style: {
          left: `${position.x}px`,
          top: `${position.y}px`
        }
      },
      message
    ),
    container
  )

  window.setTimeout(destroy, duration)

  return {
    destroy
  }
}

export function setupPopperMessageRuntime() {
  if (installed) return
  installed = true

  window.addEventListener('mousemove', (event) => {
    lastMousePosition = {
      x: event.clientX,
      y: event.clientY
    }
  })

  configureFeedbackRuntime({
    popperMessage: showPopperMessage
  })
}

配套样式由宿主提供,不进入 @qynpm/hooks

.qiyin-popper-message {
  position: fixed;
  z-index: 10000;
  user-select: none;
  pointer-events: none;
  padding: 4px 8px;
  border-radius: 4px;
  color: #fff;
  font-size: 12px;
  line-height: 1.2;
  box-shadow: 0 4px 12px rgb(15 23 42 / 16%);
  animation: qiyin-popper-message-slide-in 0.2s ease-out forwards;
}

.qiyin-popper-message.is-success {
  background: #22c55e;
}

.qiyin-popper-message.is-warning {
  background: #eab308;
}

.qiyin-popper-message.is-error {
  background: #ef4444;
}

.qiyin-popper-message.is-info {
  background: #64748b;
}

@keyframes qiyin-popper-message-slide-in {
  from {
    transform: translateY(0);
    opacity: 0.95;
  }

  to {
    transform: translateY(-24px);
    opacity: 1;
  }
}

然后在宿主初始化入口调用:

import { setupPopperMessageRuntime } from './runtime/setupPopperMessageRuntime'

setupPopperMessageRuntime()

未注入 popperMessage 时调用会抛出明确错误。

迁移边界

  • 公共组件不直接 import ElMessage / ElMessageBox / ElNotification
  • 业务薄封装优先调用统一 runtime,不自己实现确认弹窗。
  • usePopperMessage 不原样迁移,统一收敛为 popperMessage 能力。
  • @qynpm/product-preview 当前已有 feedback adapter,后续可以在宿主层把它转接到统一 runtime。
  • 第一版允许宿主只实现 message / confirm / notify,需要局部浮层提示时再接 popperMessage

ERP 当前联调示例

ERP 已在启动入口注册 runtime:

packages/erp/src/main.js

当前在老录单 transform 页面放了三处临时验证点:

  • message:覆盖提示显示 覆盖成功1
  • confirm:生成新订单号确认框显示 是否继续1?
  • notify:点击关联生产单显示 通知成功1

验证完成后,这些测试文案应恢复为正常业务文案,后续再按模块逐步替换真实调用点。

当前联调方式

根目录 vite.config.ts 里有统一联调开关:

  • ENABLE_LOCAL_QIYIN_DEBUG = true
  • ENABLE_LOCAL_QIYIN_DEBUG = false
    • @qynpm/hooks 走包正常入口

注意:

  • 当前联调只接管 @qynpm/hooks
  • 老的 @qiyin/hooks 仍然保留原有实现
  • 不要把 @qiyin/hooks 直接整体 alias 到这个新包,否则会影响现有业务

当前使用建议

如果是新拆出来、准备发布 npm 的能力:

  • 优先从 @qynpm/hooks 增加和引用

如果是老业务还在跑的历史能力:

  • 继续走原来的 @qiyin/hooks

后续迁移原则

后面其他 hooks 迁移时,按下面的顺序处理:

  1. 先判断是不是公共能力
  2. 如果依赖业务 store / router / 页面上下文,先解耦
  3. 解耦完成后再迁到 @qynpm/hooks
  4. 验证稳定后,再逐步把业务引用切到 npm 包

当前不建议原样直接迁移的,通常是这类:

  • 强依赖 @/store
  • 强依赖具体业务模块
  • 只在单一业务场景内使用的 hooks

已迁移说明

这次迁移里:

  • @qynpm/form 内部的 useSave 已改为依赖 @qynpm/hooksuseCreateInstance
  • erp 根应用已经接入 useInstanceView,可用于验证新实例池链路

构建

pnpm run build