@cys26/zpb-comp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -0
- package/package.json +63 -0
- package/src/components/chat-text-bubble/chat-text-bubble.vue +246 -0
- package/src/index.ts +15 -0
- package/src/types/chat.ts +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @cys26/zpb-comp
|
|
2
|
+
|
|
3
|
+
基于 Vue 3 的业务组件库,当前提供 **聊天文字气泡** `ChatTextBubble`,适用于 **uni-app / Vite** 等能直接编译 `node_modules` 内 `.vue` 源码的工程。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @cys26/zpb-comp
|
|
9
|
+
# 或
|
|
10
|
+
npm install @cys26/zpb-comp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
依赖:**Vue ^3.5**(已声明为 `peerDependencies`,请在使用方项目中安装 Vue)。
|
|
14
|
+
|
|
15
|
+
## 使用 ChatTextBubble
|
|
16
|
+
|
|
17
|
+
### 方式一:默认插件(全局注册 `ChatTextBubble`)
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// main.ts
|
|
21
|
+
import { createSSRApp } from 'vue'
|
|
22
|
+
import ZpbComponents from '@cys26/zpb-comp'
|
|
23
|
+
|
|
24
|
+
export function createApp() {
|
|
25
|
+
const app = createSSRApp(App)
|
|
26
|
+
app.use(ZpbComponents)
|
|
27
|
+
return { app }
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
模板中直接使用:
|
|
32
|
+
|
|
33
|
+
```vue
|
|
34
|
+
<ChatTextBubble
|
|
35
|
+
role="receiver"
|
|
36
|
+
avatar-text="收"
|
|
37
|
+
text="正文内容"
|
|
38
|
+
/>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 方式二:具名导出(按需 import)
|
|
42
|
+
|
|
43
|
+
```vue
|
|
44
|
+
<script setup lang="ts">
|
|
45
|
+
import { ChatTextBubble } from '@cys26/zpb-comp'
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<ChatTextBubble role="sender" avatar-text="我" text="发送方气泡" />
|
|
50
|
+
</template>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 方式三:深路径导入单个 SFC(便于 easycom 或路径约定)
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import ChatTextBubble from '@cys26/zpb-comp/components/chat-text-bubble/chat-text-bubble.vue'
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 发布说明(维护者)
|
|
60
|
+
|
|
61
|
+
作用域包发布到 npm 公共仓库:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pnpm publish --access public
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
若 pnpm 提示 `ERR_PNPM_GIT_UNCLEAN`,请先 `git commit`(推荐,便于与 npm 版本对应),或临时加上 `--no-git-checks`。
|
|
68
|
+
|
|
69
|
+
或使用仓库脚本(已带 `--no-git-checks`,未提交也可发版;`prepublishOnly` 会先做一次 `npm pack --dry-run` 检查):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pnpm run publish:npm
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
(若你希望脚本**不**跳过 Git 检查,可自行改回 `pnpm publish --access public` 并在发版前提交代码。)
|
|
76
|
+
|
|
77
|
+
**注意:** npm 上作用域 `@cys26` 须由你的账号或组织拥有;若包名与账号不一致,请修改根目录 `package.json` 中的 `name` 字段,并同步更新本文档与业务项目里的依赖名。
|
|
78
|
+
|
|
79
|
+
将 `repository.url` 填为真实 Git 地址后,npm 项目页会显示源码链接。
|
|
80
|
+
|
|
81
|
+
## 许可
|
|
82
|
+
|
|
83
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cys26/zpb-comp",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"description": "基于 uni-app 原生能力的业务组件库(H5 / 小程序等)",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": {
|
|
10
|
+
"pnpm": ">=9.0.0"
|
|
11
|
+
},
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"module": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./src/index.ts",
|
|
18
|
+
"import": "./src/index.ts",
|
|
19
|
+
"default": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"./components/chat-text-bubble/chat-text-bubble.vue": "./src/components/chat-text-bubble/chat-text-bubble.vue"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": [
|
|
27
|
+
"**/*.vue",
|
|
28
|
+
"**/*.scss",
|
|
29
|
+
"**/*.css"
|
|
30
|
+
],
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"vue": "^3.5.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@dcloudio/types": "^3.4.31",
|
|
36
|
+
"sass": "^1.99.0",
|
|
37
|
+
"sass-embedded": "^1.89.2",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vitepress": "^1.6.4",
|
|
40
|
+
"vue": "^3.5.34"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"uni-app",
|
|
44
|
+
"uniapp",
|
|
45
|
+
"components",
|
|
46
|
+
"chat",
|
|
47
|
+
"im"
|
|
48
|
+
],
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": ""
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"publish:npm": "pnpm publish --access public --no-git-checks",
|
|
56
|
+
"docs:dev": "vitepress dev docs",
|
|
57
|
+
"docs:build": "vitepress build docs",
|
|
58
|
+
"docs:preview": "vitepress preview docs",
|
|
59
|
+
"play:dev": "pnpm --filter zpb-playground dev:h5",
|
|
60
|
+
"play:mp-weixin": "pnpm --filter zpb-playground dev:mp-weixin",
|
|
61
|
+
"play:build:mp-weixin": "pnpm --filter zpb-playground build:mp-weixin"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { computed, onUnmounted, ref, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
defineOptions({
|
|
5
|
+
name: 'ChatTextBubble',
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
const props = withDefaults(
|
|
9
|
+
defineProps<{
|
|
10
|
+
/** 发送方:右侧绿气泡;接收方:左侧白气泡 */
|
|
11
|
+
role: 'sender' | 'receiver'
|
|
12
|
+
/** 头像内展示文案,通常取昵称首字 */
|
|
13
|
+
avatarText?: string
|
|
14
|
+
/** 完整正文;流式时父组件追加本字段,由本组件逐字展示 */
|
|
15
|
+
text: string
|
|
16
|
+
/** 为 true 时按 msPerChar 从 0 追上 text 长度 */
|
|
17
|
+
typing?: boolean
|
|
18
|
+
/** 流式结束(如收到 end);为 true 且已展示至末尾时触发 stream-complete */
|
|
19
|
+
streamClosed?: boolean
|
|
20
|
+
msPerChar?: number
|
|
21
|
+
selectable?: boolean
|
|
22
|
+
}>(),
|
|
23
|
+
{
|
|
24
|
+
avatarText: '',
|
|
25
|
+
typing: false,
|
|
26
|
+
streamClosed: true,
|
|
27
|
+
msPerChar: 45,
|
|
28
|
+
selectable: true,
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
typed: []
|
|
34
|
+
'stream-complete': []
|
|
35
|
+
}>()
|
|
36
|
+
|
|
37
|
+
const visibleLength = ref(0)
|
|
38
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
39
|
+
let completeEmitted = false
|
|
40
|
+
|
|
41
|
+
function clearTimer() {
|
|
42
|
+
if (timeoutId !== null) {
|
|
43
|
+
clearTimeout(timeoutId)
|
|
44
|
+
timeoutId = null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tryEmitStreamComplete() {
|
|
49
|
+
if (completeEmitted || !props.typing || !props.streamClosed)
|
|
50
|
+
return
|
|
51
|
+
if (props.text.length === 0)
|
|
52
|
+
return
|
|
53
|
+
if (visibleLength.value < props.text.length)
|
|
54
|
+
return
|
|
55
|
+
completeEmitted = true
|
|
56
|
+
emit('stream-complete')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scheduleTypewriterStep() {
|
|
60
|
+
const delay = Math.max(8, props.msPerChar)
|
|
61
|
+
timeoutId = setTimeout(() => {
|
|
62
|
+
timeoutId = null
|
|
63
|
+
if (!props.typing)
|
|
64
|
+
return
|
|
65
|
+
const v = visibleLength.value
|
|
66
|
+
const len = props.text.length
|
|
67
|
+
if (v < len) {
|
|
68
|
+
visibleLength.value = v + 1
|
|
69
|
+
emit('typed')
|
|
70
|
+
scheduleTypewriterStep()
|
|
71
|
+
}
|
|
72
|
+
else if (props.streamClosed) {
|
|
73
|
+
tryEmitStreamComplete()
|
|
74
|
+
}
|
|
75
|
+
}, delay)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function ensureTypewriter() {
|
|
79
|
+
if (!props.typing || timeoutId !== null)
|
|
80
|
+
return
|
|
81
|
+
if (visibleLength.value < props.text.length)
|
|
82
|
+
scheduleTypewriterStep()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function syncFromProps() {
|
|
86
|
+
clearTimer()
|
|
87
|
+
if (!props.typing) {
|
|
88
|
+
visibleLength.value = props.text.length
|
|
89
|
+
completeEmitted = false
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (visibleLength.value > props.text.length)
|
|
93
|
+
visibleLength.value = props.text.length
|
|
94
|
+
|
|
95
|
+
if (visibleLength.value < props.text.length) {
|
|
96
|
+
completeEmitted = false
|
|
97
|
+
ensureTypewriter()
|
|
98
|
+
}
|
|
99
|
+
else if (props.streamClosed && props.text.length > 0) {
|
|
100
|
+
tryEmitStreamComplete()
|
|
101
|
+
}
|
|
102
|
+
else if (props.streamClosed && props.text.length === 0) {
|
|
103
|
+
completeEmitted = false
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
watch(
|
|
108
|
+
() => [props.typing, props.text, props.streamClosed, props.msPerChar] as const,
|
|
109
|
+
() => syncFromProps(),
|
|
110
|
+
{ immediate: true },
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
onUnmounted(() => {
|
|
114
|
+
clearTimer()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const displayText = computed(() => {
|
|
118
|
+
if (!props.typing)
|
|
119
|
+
return props.text
|
|
120
|
+
return props.text.slice(0, visibleLength.value)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const showCaret = computed(() => {
|
|
124
|
+
if (!props.typing)
|
|
125
|
+
return false
|
|
126
|
+
return !props.streamClosed || visibleLength.value < props.text.length
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const avatarShow = computed(() => {
|
|
130
|
+
if (props.avatarText && props.avatarText.length > 0)
|
|
131
|
+
return props.avatarText.slice(0, 1)
|
|
132
|
+
return props.role === 'sender' ? '我' : '对'
|
|
133
|
+
})
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<template>
|
|
137
|
+
<view
|
|
138
|
+
class="row"
|
|
139
|
+
:class="role === 'sender' ? 'row-self' : 'row-peer'"
|
|
140
|
+
>
|
|
141
|
+
<view v-if="role === 'receiver'" class="avatar avatar-peer">
|
|
142
|
+
<text class="avatar-text">{{ avatarShow }}</text>
|
|
143
|
+
</view>
|
|
144
|
+
<view class="bubble-wrap">
|
|
145
|
+
<view class="bubble" :class="role === 'sender' ? 'bubble-self' : 'bubble-peer'">
|
|
146
|
+
<text class="bubble-text" :selectable="selectable">{{ displayText }}</text>
|
|
147
|
+
<text v-if="showCaret" class="caret">▍</text>
|
|
148
|
+
</view>
|
|
149
|
+
</view>
|
|
150
|
+
<view v-if="role === 'sender'" class="avatar avatar-self">
|
|
151
|
+
<text class="avatar-text">{{ avatarShow }}</text>
|
|
152
|
+
</view>
|
|
153
|
+
</view>
|
|
154
|
+
</template>
|
|
155
|
+
|
|
156
|
+
<style lang="scss" scoped>
|
|
157
|
+
.row {
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-direction: row;
|
|
160
|
+
align-items: flex-start;
|
|
161
|
+
margin-bottom: 28rpx;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.row-peer {
|
|
165
|
+
justify-content: flex-start;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.row-self {
|
|
169
|
+
justify-content: flex-end;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.avatar {
|
|
173
|
+
width: 72rpx;
|
|
174
|
+
height: 72rpx;
|
|
175
|
+
border-radius: 8rpx;
|
|
176
|
+
flex-shrink: 0;
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
justify-content: center;
|
|
180
|
+
overflow: hidden;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.avatar-peer {
|
|
184
|
+
margin-right: 16rpx;
|
|
185
|
+
background: #d0d0d0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.avatar-self {
|
|
189
|
+
margin-left: 16rpx;
|
|
190
|
+
background: #07c160;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.avatar-text {
|
|
194
|
+
font-size: 26rpx;
|
|
195
|
+
color: #fff;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.avatar-peer .avatar-text {
|
|
199
|
+
color: #333;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.bubble-wrap {
|
|
203
|
+
max-width: 70%;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.bubble {
|
|
207
|
+
position: relative;
|
|
208
|
+
padding: 18rpx 22rpx;
|
|
209
|
+
border-radius: 8rpx;
|
|
210
|
+
line-height: 1.45;
|
|
211
|
+
word-break: break-word;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.bubble-peer {
|
|
215
|
+
background: #fff;
|
|
216
|
+
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.04);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.bubble-self {
|
|
220
|
+
background: #95ec69;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.bubble-text {
|
|
224
|
+
font-size: 30rpx;
|
|
225
|
+
color: #111;
|
|
226
|
+
vertical-align: middle;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.bubble-self .bubble-text {
|
|
230
|
+
color: #000;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.caret {
|
|
234
|
+
font-size: 28rpx;
|
|
235
|
+
color: #333;
|
|
236
|
+
margin-left: 4rpx;
|
|
237
|
+
vertical-align: middle;
|
|
238
|
+
animation: blink 0.9s step-end infinite;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@keyframes blink {
|
|
242
|
+
50% {
|
|
243
|
+
opacity: 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
</style>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { App, Plugin } from 'vue'
|
|
2
|
+
import ChatTextBubble from './components/chat-text-bubble/chat-text-bubble.vue'
|
|
3
|
+
|
|
4
|
+
export type { ZpChatFrom, ZpChatMessage } from './types/chat'
|
|
5
|
+
export { ZP_CHAT_STREAM_END } from './types/chat'
|
|
6
|
+
|
|
7
|
+
export { ChatTextBubble }
|
|
8
|
+
|
|
9
|
+
const plugin: Plugin = {
|
|
10
|
+
install(app: App) {
|
|
11
|
+
app.component('ChatTextBubble', ChatTextBubble)
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default plugin
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type ZpChatFrom = 'self' | 'peer'
|
|
2
|
+
|
|
3
|
+
/** 单条消息:流式场景用 segments 多段拼接展示 */
|
|
4
|
+
export interface ZpChatMessage {
|
|
5
|
+
id: string
|
|
6
|
+
from: ZpChatFrom
|
|
7
|
+
segments: string[]
|
|
8
|
+
ts: number
|
|
9
|
+
/** 为 true 时在文本末尾显示闪烁光标(流式未结束) */
|
|
10
|
+
printing?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 与后端约定:单独一帧为该值(trim 后)表示本条流结束,不展示该帧 */
|
|
14
|
+
export const ZP_CHAT_STREAM_END = 'end'
|