@opentiny/next-sdk 0.1.1 → 0.1.2

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.
@@ -0,0 +1,54 @@
1
+ import QRCodePck from 'qrcode'
2
+
3
+ /** QrCode 类的配置信息 */
4
+ export type QrCodeOption = ConstructorParameters<typeof QrCode>[1]
5
+
6
+ /**
7
+ * 二维码工具类,根据传入的value,生成相应的二维码,并输出到 <canvas> 或 <img>上。
8
+ * @example
9
+ * const qr= new QrCode('https://www.baidu.com', { size: 100 })
10
+ *
11
+ * qr.toCanvas(canvasDom)
12
+ * qr.toImage(imgDom)
13
+ */
14
+ export class QrCode {
15
+ private value: string
16
+ private size: number
17
+ private margin: number
18
+ private color: string
19
+ private bgColor: string
20
+
21
+ constructor(value: string, { size = 200, margin = 4, color = '#000', bgColor = '#fff' }) {
22
+ this.value = value
23
+ this.size = size
24
+ this.margin = margin
25
+ this.color = color
26
+ this.bgColor = bgColor
27
+ }
28
+
29
+ get qrCodeOption() {
30
+ return {
31
+ width: this.size,
32
+ margin: this.margin,
33
+ color: {
34
+ dark: this.color, // 前景色
35
+ light: this.bgColor // 背景色
36
+ }
37
+ }
38
+ }
39
+
40
+ /** 生成二维码的 Data URL(base64 图片) */
41
+ async toDataURL(): Promise<string> {
42
+ return QRCodePck.toDataURL(this.value, this.qrCodeOption)
43
+ }
44
+
45
+ /** 渲染二维码到指定的 canvas 元素 */
46
+ async toCanvas(canvas: HTMLCanvasElement): Promise<void> {
47
+ return QRCodePck.toCanvas(canvas, this.value, this.qrCodeOption)
48
+ }
49
+
50
+ /** 渲染二维码到指定的 img 元素 */
51
+ async toImage(img: HTMLImageElement): Promise<void> {
52
+ img.src = await this.toDataURL()
53
+ }
54
+ }
@@ -0,0 +1,553 @@
1
+ import { QrCode } from './QrCode'
2
+
3
+ /** 菜单项配置接口 */
4
+ interface MenuItemConfig {
5
+ /** 菜单项标识 */
6
+ action: 'qr-code' | 'ai-chat' | 'remote-control'
7
+ /** 是否显示该菜单项 */
8
+ show?: boolean
9
+ /** 菜单项文本 */
10
+ text?: string
11
+ /** 菜单项图标SVG */
12
+ icon?: string
13
+ }
14
+
15
+ /** 配置选项接口 */
16
+ interface FloatingBlockOptions {
17
+ /** 弹出 AI 对话框的回调函数 */
18
+ onShowAIChat?: () => void
19
+
20
+ /** 遥控端页面地址,默认为: https://ai.opentiny.design/next-remoter */
21
+ qrCodeUrl?: string
22
+ /** 被遥控页面的 sessionId, 必填 */
23
+ sessionId: string
24
+ /** 菜单项配置 */
25
+ menuItems?: MenuItemConfig[]
26
+ }
27
+
28
+ // 动作类型
29
+ type ActionType = 'qr-code' | 'ai-chat' | 'remote-control'
30
+
31
+ // 默认菜单项配置
32
+ const DEFAULT_MENU_ITEMS: MenuItemConfig[] = [
33
+ {
34
+ action: 'qr-code',
35
+ show: true,
36
+ text: '弹出二维码',
37
+ icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
38
+ <path d="M3 9H6V21H3C2.45 21 2 20.55 2 20V10C2 9.45 2.45 9 3 9Z" fill="currentColor"/>
39
+ <path d="M12 2H20C21.1 2 22 2.9 22 4V20C22 21.1 21.1 22 20 22H12C10.9 22 10 21.1 10 20V4C10 2.9 10.9 2 12 2ZM12 20H20V4H12V20Z" fill="currentColor"/>
40
+ <path d="M15 7H17V9H15V7Z" fill="currentColor"/>
41
+ <path d="M15 11H17V13H15V11Z" fill="currentColor"/>
42
+ <path d="M15 15H17V17H15V15Z" fill="currentColor"/>
43
+ <path d="M19 7H21V9H19V7Z" fill="currentColor"/>
44
+ <path d="M19 11H21V13H19V11Z" fill="currentColor"/>
45
+ <path d="M19 15H21V17H19V15Z" fill="currentColor"/>
46
+ </svg>`
47
+ },
48
+ {
49
+ action: 'ai-chat',
50
+ show: true,
51
+ text: '弹出AI对话框',
52
+ icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
53
+ <path d="M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2ZM20 16H6L4 18V4H20V16Z" fill="currentColor"/>
54
+ <path d="M7 9H17V11H7V9Z" fill="currentColor"/>
55
+ <path d="M7 12H14V14H7V12Z" fill="currentColor"/>
56
+ </svg>`
57
+ },
58
+ {
59
+ action: 'remote-control',
60
+ show: true,
61
+ text: '发送遥控指令',
62
+ icon: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
63
+ <path d="M7 2H17C18.1 2 19 2.9 19 4V20C19 21.1 18.1 22 17 22H7C5.9 22 5 21.1 5 20V4C5 2.9 5.9 2 7 2ZM7 4V20H17V4H7Z" fill="currentColor"/>
64
+ <path d="M9 6H15V8H9V6Z" fill="currentColor"/>
65
+ <path d="M9 10H15V12H9V10Z" fill="currentColor"/>
66
+ <path d="M9 14H15V16H9V14Z" fill="currentColor"/>
67
+ <path d="M9 18H15V20H9V18Z" fill="currentColor"/>
68
+ </svg>`
69
+ }
70
+ ]
71
+
72
+ class FloatingBlock {
73
+ private options: FloatingBlockOptions
74
+ private isExpanded = false
75
+ private floatingBlock!: HTMLDivElement
76
+ private dropdownMenu!: HTMLDivElement
77
+ private menuItems: MenuItemConfig[]
78
+
79
+ constructor(options: FloatingBlockOptions) {
80
+ if (!options.sessionId) {
81
+ throw new Error('sessionId is required')
82
+ }
83
+
84
+ this.options = {
85
+ qrCodeUrl: options.qrCodeUrl || 'https://ai.opentiny.design/next-remoter',
86
+ ...options
87
+ }
88
+
89
+ // 合并默认菜单项配置和用户配置
90
+ this.menuItems = this.mergeMenuItems(options.menuItems)
91
+
92
+ this.init()
93
+ }
94
+
95
+ /**
96
+ * 合并菜单项配置
97
+ * @param userMenuItems 用户自定义菜单项配置
98
+ * @returns 合并后的菜单项配置
99
+ */
100
+ private mergeMenuItems(userMenuItems?: MenuItemConfig[]): MenuItemConfig[] {
101
+ if (!userMenuItems) {
102
+ return DEFAULT_MENU_ITEMS
103
+ }
104
+
105
+ return DEFAULT_MENU_ITEMS.map((defaultItem) => {
106
+ const userItem = userMenuItems.find((item) => item.action === defaultItem.action)
107
+ if (userItem) {
108
+ return {
109
+ ...defaultItem,
110
+ ...userItem,
111
+ // 确保show属性存在,默认为true
112
+ show: userItem.show !== undefined ? userItem.show : defaultItem.show
113
+ }
114
+ }
115
+ return defaultItem
116
+ })
117
+ }
118
+
119
+ private init(): void {
120
+ this.createFloatingBlock()
121
+ this.createDropdownMenu()
122
+ this.bindEvents()
123
+ this.addStyles()
124
+ }
125
+
126
+ // 创建主浮动块
127
+ private createFloatingBlock(): void {
128
+ this.floatingBlock = document.createElement('div')
129
+ this.floatingBlock.className = 'tiny-remoter-floating-block'
130
+ this.floatingBlock.innerHTML = `
131
+ <div class="tiny-remoter-floating-block__icon">
132
+ <img style="display: block; width: 40px;" src="https://ai.opentiny.design/next-remoter/svgs/logo-next-bg-blue-left.svg" alt="icon" />
133
+ </div>
134
+ `
135
+
136
+ document.body.appendChild(this.floatingBlock)
137
+ }
138
+
139
+ // 创建下拉菜单
140
+ private createDropdownMenu(): void {
141
+ this.dropdownMenu = document.createElement('div')
142
+ this.dropdownMenu.className = 'tiny-remoter-floating-dropdown'
143
+
144
+ // 根据配置动态生成菜单项
145
+ const menuItemsHTML = this.menuItems
146
+ .filter((item) => item.show !== false) // 过滤掉show为false的菜单项
147
+ .map(
148
+ (item) => `
149
+ <div class="tiny-remoter-dropdown-item" data-action="${item.action}">
150
+ <div class="tiny-remoter-dropdown-item__icon">
151
+ ${item.icon}
152
+ </div>
153
+ <span>${item.text}</span>
154
+ </div>
155
+ `
156
+ )
157
+ .join('')
158
+
159
+ this.dropdownMenu.innerHTML = menuItemsHTML
160
+
161
+ document.body.appendChild(this.dropdownMenu)
162
+ }
163
+
164
+ private bindEvents(): void {
165
+ // 绑定浮动块点击事件
166
+ this.floatingBlock.addEventListener('click', () => {
167
+ this.toggleDropdown()
168
+ })
169
+
170
+ // 绑定菜单项点击事件
171
+ this.dropdownMenu.addEventListener('click', (e: Event) => {
172
+ const target = e.target as HTMLElement
173
+ const actionItem = target.closest('.tiny-remoter-dropdown-item') as HTMLElement
174
+ const action = actionItem?.dataset.action as ActionType
175
+ if (action) {
176
+ this.handleAction(action)
177
+ }
178
+ })
179
+
180
+ // 点击外部关闭菜单
181
+ document.addEventListener('click', (e: Event) => {
182
+ const target = e.target as HTMLElement
183
+ if (!this.floatingBlock.contains(target) && !this.dropdownMenu.contains(target)) {
184
+ this.closeDropdown()
185
+ }
186
+ })
187
+
188
+ // ESC键关闭菜单
189
+ document.addEventListener('keydown', (e: KeyboardEvent) => {
190
+ if (e.key === 'Escape') {
191
+ this.closeDropdown()
192
+ }
193
+ })
194
+ }
195
+
196
+ private toggleDropdown(): void {
197
+ if (this.isExpanded) {
198
+ this.closeDropdown()
199
+ } else {
200
+ this.openDropdown()
201
+ }
202
+ }
203
+
204
+ private openDropdown(): void {
205
+ this.isExpanded = true
206
+ this.floatingBlock.classList.add('expanded')
207
+ this.dropdownMenu.classList.add('show')
208
+ }
209
+
210
+ private closeDropdown(): void {
211
+ this.isExpanded = false
212
+ this.floatingBlock.classList.remove('expanded')
213
+ this.dropdownMenu.classList.remove('show')
214
+ }
215
+
216
+ private handleAction(action: ActionType): void {
217
+ switch (action) {
218
+ case 'qr-code':
219
+ this.showQRCode()
220
+ break
221
+ case 'ai-chat':
222
+ this.showAIChat()
223
+ break
224
+ case 'remote-control':
225
+ this.showRemoteControl()
226
+ break
227
+ }
228
+ this.closeDropdown()
229
+ }
230
+
231
+ // 创建二维码弹窗
232
+ private async showQRCode(): Promise<void> {
233
+ const qrCode = new QrCode(this.options.qrCodeUrl + '?sessionId=' + this.options.sessionId, {})
234
+ const base64 = await qrCode.toDataURL()
235
+ const modal = this.createModal(
236
+ '扫码前往智能遥控器',
237
+ `
238
+ <div style="text-align: center; padding: 20px;">
239
+ <div style="width: 200px; height: 200px; background: #f0f0f0; margin: 0 auto 20px; display: flex; align-items: center; justify-content: center; border-radius: 8px;">
240
+ <img src="${base64}" alt="二维码" style="width: 100%; height: 100%; object-fit: contain;">
241
+ </div>
242
+ <p style="color: #666; margin: 0;">请使用手机微信或者浏览器扫描二维码跳转到智能遥控器</p>
243
+ </div>
244
+ `
245
+ )
246
+ this.showModal(modal)
247
+ }
248
+
249
+ // 创建AI对话弹窗--- 暂时调 “用户函数”
250
+ private showAIChat(): void {
251
+ this.options.onShowAIChat?.()
252
+ }
253
+
254
+ // 创建遥控指令弹窗
255
+ private showRemoteControl(): void {
256
+ const modal = this.createModal(
257
+ '输入需要发送的用户名',
258
+ `
259
+ <div style="padding: 20px;">
260
+ <div style="display: flex; gap: 10px;">
261
+ <input type="text" placeholder="输入用户名" style="flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 4px;">
262
+ <button style="padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">发送</button>
263
+ </div>
264
+ </div>
265
+ `
266
+ )
267
+ this.showModal(modal)
268
+ }
269
+
270
+ private createModal(title: string, content: string): HTMLDivElement {
271
+ const modal = document.createElement('div')
272
+ modal.className = 'tiny-remoter-floating-modal'
273
+ modal.innerHTML = `
274
+ <div class="tiny-remoter-modal-overlay"></div>
275
+ <div class="tiny-remoter-modal-content">
276
+ <div class="tiny-remoter-modal-header">
277
+ <h3>${title}</h3>
278
+ <button class="tiny-remoter-modal-close">&times;</button>
279
+ </div>
280
+ <div class="tiny-remoter-modal-body">
281
+ ${content}
282
+ </div>
283
+ </div>
284
+ `
285
+
286
+ // 绑定关闭事件
287
+ const closeBtn = modal.querySelector('.tiny-remoter-modal-close') as HTMLButtonElement
288
+ const overlay = modal.querySelector('.tiny-remoter-modal-overlay') as HTMLDivElement
289
+
290
+ closeBtn.addEventListener('click', () => this.hideModal(modal))
291
+ overlay.addEventListener('click', () => this.hideModal(modal))
292
+
293
+ return modal
294
+ }
295
+
296
+ private showModal(modal: HTMLDivElement): void {
297
+ document.body.appendChild(modal)
298
+ // 添加显示动画
299
+ setTimeout(() => modal.classList.add('show'), 10)
300
+ }
301
+
302
+ private hideModal(modal: HTMLDivElement): void {
303
+ modal.classList.remove('show')
304
+ setTimeout(() => {
305
+ if (modal.parentNode) {
306
+ modal.parentNode.removeChild(modal)
307
+ }
308
+ }, 100)
309
+ }
310
+
311
+ // 创建样式表
312
+ private addStyles(): void {
313
+ const style = document.createElement('style')
314
+ style.textContent = `
315
+ /* 浮动块样式 */
316
+ .tiny-remoter-floating-block {
317
+ position: fixed;
318
+ bottom: 30px;
319
+ right: 30px;
320
+ width: 60px;
321
+ height: 60px;
322
+ cursor: pointer;
323
+ display: flex;
324
+ align-items: center;
325
+ justify-content: center;
326
+ color: white;
327
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
328
+ z-index: 99;
329
+ overflow: hidden;
330
+ border-radius: 50%;
331
+ }
332
+
333
+ .tiny-remoter-floating-block__icon {
334
+ transform: scale(0.8);
335
+ transition: transform 0.3s ease;
336
+ }
337
+
338
+ .tiny-remoter-floating-block__icon:hover {
339
+ transform: scale(1.1);
340
+ }
341
+
342
+ .tiny-remoter-floating-block.expanded .tiny-remoter-floating-block__icon {
343
+ transform: scale(1.1);
344
+ }
345
+
346
+ /* 下拉菜单样式 */
347
+ .tiny-remoter-floating-dropdown {
348
+ position: fixed;
349
+ bottom: 100px;
350
+ right: 30px;
351
+ background: white;
352
+ border-radius: 16px;
353
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
354
+ padding: 8px;
355
+ opacity: 0;
356
+ visibility: hidden;
357
+ transform: translateY(20px) scale(0.95);
358
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
359
+ z-index: 999;
360
+ min-width: 200px;
361
+ }
362
+
363
+ .tiny-remoter-floating-dropdown.show {
364
+ opacity: 1;
365
+ visibility: visible;
366
+ transform: translateY(0) scale(1);
367
+ }
368
+
369
+ .tiny-remoter-dropdown-item {
370
+ display: flex;
371
+ align-items: center;
372
+ gap: 12px;
373
+ padding: 12px 16px;
374
+ border-radius: 12px;
375
+ cursor: pointer;
376
+ transition: all 0.2s ease;
377
+ color: #333;
378
+ }
379
+
380
+ .tiny-remoter-dropdown-item:hover {
381
+ background: #f8f9fa;
382
+ transform: translateX(4px);
383
+ }
384
+
385
+ .tiny-remoter-dropdown-item__icon {
386
+ display: flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ width: 32px;
390
+ height: 32px;
391
+ background: #f8f9fa;
392
+ border-radius: 8px;
393
+ color: #667eea;
394
+ }
395
+
396
+ /* 弹窗样式 */
397
+ .tiny-remoter-floating-modal {
398
+ position: fixed;
399
+ top: 0;
400
+ left: 0;
401
+ width: 100%;
402
+ height: 100%;
403
+ z-index: 2000;
404
+ display: flex;
405
+ align-items: center;
406
+ justify-content: center;
407
+ }
408
+
409
+ .tiny-remoter-modal-overlay {
410
+ position: absolute;
411
+ top: 0;
412
+ left: 0;
413
+ width: 100%;
414
+ height: 100%;
415
+ background: rgba(0, 0, 0, 0.5);
416
+ backdrop-filter: blur(4px);
417
+ opacity: 0;
418
+ transition: opacity 0.3s ease;
419
+ }
420
+
421
+ .tiny-remoter-modal-content {
422
+ background: white;
423
+ border-radius: 16px;
424
+ box-shadow: 0 25px 80px rgba(0, 0, 0, 0.2);
425
+ max-width: 500px;
426
+ width: 90%;
427
+ max-height: 80vh;
428
+ overflow: hidden;
429
+ transform: scale(0.9) translateY(20px);
430
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
431
+ }
432
+
433
+ .tiny-remoter-floating-modal.show .tiny-remoter-modal-overlay {
434
+ opacity: 1;
435
+ }
436
+
437
+ .tiny-remoter-floating-modal.show .tiny-remoter-modal-content {
438
+ transform: scale(1) translateY(0);
439
+ }
440
+
441
+ .tiny-remoter-modal-header {
442
+ display: flex;
443
+ align-items: center;
444
+ justify-content: space-between;
445
+ padding: 20px 24px;
446
+ border-bottom: 1px solid #f0f0f0;
447
+ }
448
+
449
+ .tiny-remoter-modal-header h3 {
450
+ margin: 0;
451
+ font-size: 18px;
452
+ font-weight: 600;
453
+ color: #333;
454
+ }
455
+
456
+ .tiny-remoter-modal-close {
457
+ background: none;
458
+ border: none;
459
+ font-size: 24px;
460
+ cursor: pointer;
461
+ color: #999;
462
+ padding: 0;
463
+ width: 32px;
464
+ height: 32px;
465
+ border-radius: 50%;
466
+ display: flex;
467
+ align-items: center;
468
+ justify-content: center;
469
+ transition: all 0.2s ease;
470
+ }
471
+
472
+ .tiny-remoter-modal-close:hover {
473
+ background: #f5f5f5;
474
+ color: #666;
475
+ }
476
+
477
+ .tiny-remoter-modal-body {
478
+ padding: 24px;
479
+ }
480
+
481
+ /* 响应式设计 */
482
+ @media (max-width: 768px) {
483
+ .tiny-remoter-floating-block {
484
+ bottom: 20px;
485
+ right: 20px;
486
+ width: 56px;
487
+ height: 56px;
488
+ }
489
+
490
+ .tiny-remoter-floating-dropdown {
491
+ bottom: 90px;
492
+ right: 20px;
493
+ min-width: 180px;
494
+ }
495
+
496
+ .tiny-remoter-modal-content {
497
+ width: 95%;
498
+ margin: 20px;
499
+ }
500
+ }
501
+
502
+ /* 深色主题支持 */
503
+ @media (prefers-color-scheme: dark) {
504
+ .tiny-remoter-floating-dropdown {
505
+ background: #1a1a1a;
506
+ color: white;
507
+ }
508
+
509
+ .tiny-remoter-dropdown-item {
510
+ color: white;
511
+ }
512
+
513
+ .tiny-remoter-dropdown-item:hover {
514
+ background: #2a2a2a;
515
+ }
516
+
517
+ .tiny-remoter-dropdown-item__icon {
518
+ background: #2a2a2a;
519
+ }
520
+
521
+ .tiny-remoter-modal-content {
522
+ background: #1a1a1a;
523
+ color: white;
524
+ }
525
+
526
+ .tiny-remoter-modal-header {
527
+ border-bottom-color: #333;
528
+ }
529
+
530
+ .tiny-remoter-modal-header h3 {
531
+ color: white;
532
+ }
533
+ }
534
+ `
535
+
536
+ document.head.appendChild(style)
537
+ }
538
+
539
+ // 销毁组件
540
+ public destroy(): void {
541
+ if (this.floatingBlock.parentNode) {
542
+ this.floatingBlock.parentNode.removeChild(this.floatingBlock)
543
+ }
544
+ if (this.dropdownMenu.parentNode) {
545
+ this.dropdownMenu.parentNode.removeChild(this.dropdownMenu)
546
+ }
547
+ }
548
+ }
549
+
550
+ // 导出组件
551
+ export const createRemoter = (options = {} as FloatingBlockOptions) => {
552
+ return new FloatingBlock(options)
553
+ }