From bd846534398b758c6bbf5d524453849adb974337 Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Thu, 2 Jan 2025 22:58:26 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(debug):=20=E6=94=AF=E6=8C=81=E6=9E=84?= =?UTF-8?q?=E9=80=A0=E8=81=8A=E5=A4=A9=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 + .../chat_input/components/audio_insert.tsx | 254 +++++++++++++++++ .../chat_input/components/dice_insert.tsx | 31 +++ .../chat_input/components/emoji_picker.tsx | 83 ++++++ .../chat_input/components/file_insert.tsx | 125 +++++++++ .../chat_input/components/image_insert.tsx | 114 ++++++++ .../chat_input/components/music_insert.tsx | 256 ++++++++++++++++++ .../chat_input/components/reply_insert.tsx | 58 ++++ .../chat_input/components/rps_insert.tsx | 31 +++ .../components/show_structed_message.tsx | 32 +++ .../chat_input/components/video_insert.tsx | 126 +++++++++ .../chat_input/formats/emoji_blot.ts | 41 +++ .../chat_input/formats/image_blot.ts | 30 ++ .../chat_input/formats/reply_blot.ts | 43 +++ src/components/chat_input/index.tsx | 207 ++++++++++++++ src/components/chat_input/modal.tsx | 49 ++++ src/components/modal.tsx | 3 + src/components/onebot/api/debug.tsx | 4 +- src/components/onebot/send_modal.tsx | 12 +- src/components/sidebar/index.tsx | 4 +- src/contexts/dialog.tsx | 25 +- src/hooks/use_show_strcuted_message.tsx | 25 ++ src/main.tsx | 15 +- src/pages/dashboard/debug/websocket/index.tsx | 2 + src/pages/qq_login.tsx | 2 +- src/pages/web_login.tsx | 2 +- src/styles/globals.css | 21 +- src/types/onebot/segment.ts | 4 +- src/utils/onebot.ts | 53 +++- src/utils/url.ts | 9 + 30 files changed, 1630 insertions(+), 35 deletions(-) create mode 100644 src/components/chat_input/components/audio_insert.tsx create mode 100644 src/components/chat_input/components/dice_insert.tsx create mode 100644 src/components/chat_input/components/emoji_picker.tsx create mode 100644 src/components/chat_input/components/file_insert.tsx create mode 100644 src/components/chat_input/components/image_insert.tsx create mode 100644 src/components/chat_input/components/music_insert.tsx create mode 100644 src/components/chat_input/components/reply_insert.tsx create mode 100644 src/components/chat_input/components/rps_insert.tsx create mode 100644 src/components/chat_input/components/show_structed_message.tsx create mode 100644 src/components/chat_input/components/video_insert.tsx create mode 100644 src/components/chat_input/formats/emoji_blot.ts create mode 100644 src/components/chat_input/formats/image_blot.ts create mode 100644 src/components/chat_input/formats/reply_blot.ts create mode 100644 src/components/chat_input/index.tsx create mode 100644 src/components/chat_input/modal.tsx create mode 100644 src/hooks/use_show_strcuted_message.tsx diff --git a/package.json b/package.json index ca39ef9..c778f76 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@nextui-org/chip": "^2.2.4", "@nextui-org/code": "2.2.4", "@nextui-org/dropdown": "^2.3.7", + "@nextui-org/form": "^2.1.7", "@nextui-org/image": "^2.2.3", "@nextui-org/input": "2.4.6", "@nextui-org/kbd": "2.2.4", @@ -56,12 +57,14 @@ "motion": "^11.15.0", "qface": "^1.4.1", "qrcode.react": "^4.2.0", + "quill": "^2.0.3", "react": "19.0.0", "react-dom": "19.0.0", "react-error-boundary": "^5.0.0", "react-hook-form": "^7.54.2", "react-hot-toast": "^2.4.1", "react-icons": "^5.4.0", + "react-quilljs": "^2.0.5", "react-redux": "^9.2.0", "react-responsive": "^10.0.0", "react-router-dom": "7.1.0", @@ -78,6 +81,7 @@ "@react-types/shared": "^3.26.0", "@trivago/prettier-plugin-sort-imports": "^5.2.0", "@types/event-source-polyfill": "^1.0.5", + "@types/fabric": "^5.3.9", "@types/node": "22.10.2", "@types/react": "19.0.2", "@types/react-dom": "19.0.2", diff --git a/src/components/chat_input/components/audio_insert.tsx b/src/components/chat_input/components/audio_insert.tsx new file mode 100644 index 0000000..f5dff8e --- /dev/null +++ b/src/components/chat_input/components/audio_insert.tsx @@ -0,0 +1,254 @@ +import { Button } from '@nextui-org/button' +import { Input } from '@nextui-org/input' +import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover' +import { Tooltip } from '@nextui-org/tooltip' +import clsx from 'clsx' +import { useEffect, useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { FaMicrophone } from 'react-icons/fa6' +import { IoMic } from 'react-icons/io5' +import { MdEdit, MdUpload } from 'react-icons/md' + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message' + +import { isURI } from '@/utils/url' + +import type { OB11Segment } from '@/types/onebot' + +const AudioInsert = () => { + const [audioUrl, setAudioUrl] = useState('') + const audioInputRef = useRef(null) + const showStructuredMessage = useShowStructuredMessage() + const showAudioSegment = (file: string) => { + const messages: OB11Segment[] = [ + { + type: 'record', + data: { + file: file + } + } + ] + showStructuredMessage(messages) + } + + const [isRecording, setIsRecording] = useState(false) + const mediaRecorderRef = useRef(null) + const audioChunksRef = useRef([]) + const [audioPreview, setAudioPreview] = useState(null) + const [showPreview, setShowPreview] = useState(false) + const streamRef = useRef(null) + const [recordingTime, setRecordingTime] = useState(0) + const recordingIntervalRef = useRef(null) + + useEffect(() => { + if (isRecording) { + navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => { + streamRef.current = stream + const recorder = new MediaRecorder(stream) + mediaRecorderRef.current = recorder + recorder.start() + recorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data) + } + } + recorder.onstop = () => { + if (audioChunksRef.current.length > 0) { + const audioBlob = new Blob(audioChunksRef.current, { + type: 'audio/wav' + }) + const reader = new FileReader() + reader.readAsDataURL(audioBlob) + reader.onloadend = () => { + const base64Audio = reader.result as string + setAudioPreview(base64Audio) + setShowPreview(true) + } + audioChunksRef.current = [] + } + stream.getTracks().forEach((track) => track.stop()) + } + }) + recordingIntervalRef.current = setInterval(() => { + setRecordingTime((prevTime) => prevTime + 1) + }, 1000) + } else { + mediaRecorderRef.current?.stop() + if (recordingIntervalRef.current) { + clearInterval(recordingIntervalRef.current) + recordingIntervalRef.current = null + } + } + }, [isRecording]) + + const startRecording = () => { + setAudioPreview(null) + setShowPreview(false) + setRecordingTime(0) + setIsRecording(true) + } + + const stopRecording = () => { + setIsRecording(false) + } + + const handleShowPreview = () => { + if (audioPreview) { + showAudioSegment(audioPreview) + } + } + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60) + const seconds = time % 60 + return `${minutes}:${seconds.toString().padStart(2, '0')}` + } + + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setAudioUrl(e.target.value)} + placeholder="请输入音频地址" + /> + + +
+ + +
+ + + +
+
+ +
+ + {showPreview && audioPreview && ( + + )} +
+ {(isRecording || audioPreview) && ( +
+ + 录制时长: {formatTime(recordingTime)} +
+ )} + {showPreview && audioPreview && ( +
+
+
+
+ + { + const file = e.target.files?.[0] + if (!file) { + return + } + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = (event) => { + const dataURL = event.target?.result + showAudioSegment(dataURL as string) + e.target.value = '' + } + }} + /> + + ) +} + +export default AudioInsert diff --git a/src/components/chat_input/components/dice_insert.tsx b/src/components/chat_input/components/dice_insert.tsx new file mode 100644 index 0000000..2bb9983 --- /dev/null +++ b/src/components/chat_input/components/dice_insert.tsx @@ -0,0 +1,31 @@ +import { Button } from '@nextui-org/button' +import { Tooltip } from '@nextui-org/tooltip' +import { BsDice3Fill } from 'react-icons/bs' + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message' + +const DiceInsert = () => { + const showStructuredMessage = useShowStructuredMessage() + + return ( + + + + ) +} + +export default DiceInsert diff --git a/src/components/chat_input/components/emoji_picker.tsx b/src/components/chat_input/components/emoji_picker.tsx new file mode 100644 index 0000000..10f8f17 --- /dev/null +++ b/src/components/chat_input/components/emoji_picker.tsx @@ -0,0 +1,83 @@ +import { Button } from '@nextui-org/button' +import { Image } from '@nextui-org/image' +import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover' +import { Tooltip } from '@nextui-org/tooltip' +import { data, getUrl } from 'qface' +import { useEffect, useRef, useState } from 'react' +import { MdEmojiEmotions } from 'react-icons/md' + +import { EmojiValue } from '../formats/emoji_blot' + +const emojis = data.map((item) => { + return { + alt: item.QDes, + src: getUrl(item.QSid), + id: item.QSid + } as EmojiValue +}) + +export interface EmojiPickerProps { + onInsertEmoji: (emoji: EmojiValue) => void + onOpenChange: (open: boolean) => void +} + +const EmojiPicker = ({ onInsertEmoji, onOpenChange }: EmojiPickerProps) => { + const [visibleEmojis, setVisibleEmojis] = useState([]) + const [isPopoverOpen, setIsPopoverOpen] = useState(false) + const containerRef = useRef(null) + useEffect(() => { + if (isPopoverOpen) { + setVisibleEmojis([]) // Reset visible emojis + requestAnimationFrame(() => loadEmojis()) // Start loading emojis + } + }, [isPopoverOpen]) + + const loadEmojis = (index = 0, batchSize = 10) => { + if (index < emojis.length) { + setVisibleEmojis((prev) => [ + ...prev, + ...emojis.slice(index, index + batchSize) + ]) + requestAnimationFrame(() => loadEmojis(index + batchSize, batchSize)) + } + } + return ( +
+ { + onOpenChange(v) + setIsPopoverOpen(v) + }} + > + +
+ + + +
+
+ + {visibleEmojis.map((emoji) => ( + + ))} + +
+
+ ) +} + +export default EmojiPicker diff --git a/src/components/chat_input/components/file_insert.tsx b/src/components/chat_input/components/file_insert.tsx new file mode 100644 index 0000000..f6e9e14 --- /dev/null +++ b/src/components/chat_input/components/file_insert.tsx @@ -0,0 +1,125 @@ +import { Button } from '@nextui-org/button' +import { Input } from '@nextui-org/input' +import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover' +import { Tooltip } from '@nextui-org/tooltip' +import { useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { FaFolder } from 'react-icons/fa6' +import { LuFilePlus2 } from 'react-icons/lu' +import { MdEdit, MdUpload } from 'react-icons/md' + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message' + +import { isURI } from '@/utils/url' + +import type { OB11Segment } from '@/types/onebot' + +const FileInsert = () => { + const [fileUrl, setFileUrl] = useState('') + const fileInputRef = useRef(null) + const showStructuredMessage = useShowStructuredMessage() + const showFileSegment = (file: string) => { + const messages: OB11Segment[] = [ + { + type: 'file', + data: { + file: file + } + } + ] + showStructuredMessage(messages) + } + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setFileUrl(e.target.value)} + placeholder="请输入文件地址" + /> + + +
+
+
+ + { + const file = e.target.files?.[0] + if (!file) { + return + } + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = (event) => { + const dataURL = event.target?.result + showFileSegment(dataURL as string) + e.target.value = '' + } + }} + /> + + ) +} + +export default FileInsert diff --git a/src/components/chat_input/components/image_insert.tsx b/src/components/chat_input/components/image_insert.tsx new file mode 100644 index 0000000..6f3af54 --- /dev/null +++ b/src/components/chat_input/components/image_insert.tsx @@ -0,0 +1,114 @@ +import { Button } from '@nextui-org/button' +import { Input } from '@nextui-org/input' +import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover' +import { Tooltip } from '@nextui-org/tooltip' +import { useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { MdAddPhotoAlternate, MdEdit, MdImage, MdUpload } from 'react-icons/md' + +import { isURI } from '@/utils/url' + +export interface ImageInsertProps { + insertImage: (url: string) => void + onOpenChange: (open: boolean) => void +} + +const ImageInsert = ({ insertImage, onOpenChange }: ImageInsertProps) => { + const [imgUrl, setImgUrl] = useState('') + const imageInputRef = useRef(null) + + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setImgUrl(e.target.value)} + placeholder="请输入图片地址" + /> + + +
+
+
+ + { + const file = e.target.files?.[0] + if (!file) { + return + } + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = (event) => { + const dataURL = event.target?.result + insertImage(dataURL as string) + e.target.value = '' + } + }} + /> + + ) +} + +export default ImageInsert diff --git a/src/components/chat_input/components/music_insert.tsx b/src/components/chat_input/components/music_insert.tsx new file mode 100644 index 0000000..6b2ae4e --- /dev/null +++ b/src/components/chat_input/components/music_insert.tsx @@ -0,0 +1,256 @@ +import { Button } from '@nextui-org/button' +import { Form } from '@nextui-org/form' +import { Input } from '@nextui-org/input' +import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover' +import { Select, SelectItem } from '@nextui-org/select' +import type { SharedSelection } from '@nextui-org/system' +import { Tab, Tabs } from '@nextui-org/tabs' +import { Tooltip } from '@nextui-org/tooltip' +import type { Key } from '@react-types/shared' +import { useRef, useState } from 'react' +import { Controller, useForm } from 'react-hook-form' +import toast from 'react-hot-toast' +import { IoMusicalNotes } from 'react-icons/io5' +import { TbMusicPlus } from 'react-icons/tb' + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message' + +import { isURI } from '@/utils/url' + +import type { + CustomMusicSegment, + MusicSegment, + OB11Segment +} from '@/types/onebot' + +type MusicData = CustomMusicSegment['data'] | MusicSegment['data'] + +const MusicInsert = () => { + const [musicId, setMusicId] = useState('') + const [musicType, setMusicType] = useState(new Set(['163'])) + const [mode, setMode] = useState('default') + const containerRef = useRef(null) + const { control, handleSubmit, reset } = useForm< + Omit + >({ + defaultValues: { + url: '', + audio: '', + title: '', + image: '', + content: '' + } + }) + const showStructuredMessage = useShowStructuredMessage() + + const showMusicSegment = (data: MusicData) => { + const messages: OB11Segment[] = [] + if (data.type === 'custom') { + messages.push({ + type: 'music', + data: { + ...data, + type: 'custom' + } + }) + } else { + messages.push({ + type: 'music', + data + }) + } + showStructuredMessage(messages) + } + + const onSubmit = (data: Omit) => { + showMusicSegment({ + type: 'custom', + ...data + }) + reset() + } + + return ( +
+ + +
+ + + +
+
+ + + + + setMusicId(e.target.value)} + placeholder="请输入音乐ID" + label="音乐ID" + /> + + + +
+ ( + { + return !isURI(v) ? '请输入正确的音乐URL' : null + }} + size="sm" + placeholder="请输入音乐URL" + label="音乐URL" + /> + )} + /> + ( + { + return !isURI(v) ? '请输入正确的音频URL' : null + }} + size="sm" + placeholder="请输入音频URL" + label="音频URL" + /> + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + +
+
+
+
+
+ ) +} + +export default MusicInsert diff --git a/src/components/chat_input/components/reply_insert.tsx b/src/components/chat_input/components/reply_insert.tsx new file mode 100644 index 0000000..a885864 --- /dev/null +++ b/src/components/chat_input/components/reply_insert.tsx @@ -0,0 +1,58 @@ +import { Button } from '@nextui-org/button' +import { Input } from '@nextui-org/input' +import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover' +import { Tooltip } from '@nextui-org/tooltip' +import { useState } from 'react' +import { BsChatQuoteFill } from 'react-icons/bs' +import { MdAdd } from 'react-icons/md' + +export interface ReplyInsertProps { + insertReply: (messageId: string) => void +} + +const ReplyInsert = ({ insertReply }: ReplyInsertProps) => { + const [replyId, setReplyId] = useState('') + + return ( + <> + + +
+ + + +
+
+ + { + const value = e.target.value + const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/ + if (isNumberReg.test(value)) { + setReplyId(value) + } + }} + /> + + +
+ + ) +} + +export default ReplyInsert diff --git a/src/components/chat_input/components/rps_insert.tsx b/src/components/chat_input/components/rps_insert.tsx new file mode 100644 index 0000000..c4f9957 --- /dev/null +++ b/src/components/chat_input/components/rps_insert.tsx @@ -0,0 +1,31 @@ +import { Button } from '@nextui-org/button' +import { Tooltip } from '@nextui-org/tooltip' +import { LiaHandScissors } from 'react-icons/lia' + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message' + +const RPSInsert = () => { + const showStructuredMessage = useShowStructuredMessage() + + return ( + + + + ) +} + +export default RPSInsert diff --git a/src/components/chat_input/components/show_structed_message.tsx b/src/components/chat_input/components/show_structed_message.tsx new file mode 100644 index 0000000..e02a9db --- /dev/null +++ b/src/components/chat_input/components/show_structed_message.tsx @@ -0,0 +1,32 @@ +import { Snippet } from '@nextui-org/snippet' + +import { OB11Segment } from '@/types/onebot' + +export interface ShowStructedMessageProps { + messages: OB11Segment[] +} + +const ShowStructedMessage = ({ messages }: ShowStructedMessageProps) => { + return ( + + {JSON.stringify(messages, null, 2) + .split('\n') + .map((line, i) => ( + + {line} + + ))} + + ) +} + +export default ShowStructedMessage diff --git a/src/components/chat_input/components/video_insert.tsx b/src/components/chat_input/components/video_insert.tsx new file mode 100644 index 0000000..b4fc8e5 --- /dev/null +++ b/src/components/chat_input/components/video_insert.tsx @@ -0,0 +1,126 @@ +import { Button } from '@nextui-org/button' +import { Input } from '@nextui-org/input' +import { Popover, PopoverContent, PopoverTrigger } from '@nextui-org/popover' +import { Tooltip } from '@nextui-org/tooltip' +import { useRef, useState } from 'react' +import toast from 'react-hot-toast' +import { IoVideocam } from 'react-icons/io5' +import { MdEdit, MdUpload } from 'react-icons/md' +import { TbVideoPlus } from 'react-icons/tb' + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message' + +import { isURI } from '@/utils/url' + +import type { OB11Segment } from '@/types/onebot' + +const VideoInsert = () => { + const [videoUrl, setVideoUrl] = useState('') + const videoInputRef = useRef(null) + const showStructuredMessage = useShowStructuredMessage() + const showVideoSegment = (file: string) => { + const messages: OB11Segment[] = [ + { + type: 'video', + data: { + file: file + } + } + ] + showStructuredMessage(messages) + } + return ( + <> + + +
+ + + +
+
+ + + + + + +
+ + + +
+
+ + setVideoUrl(e.target.value)} + placeholder="请输入视频地址" + /> + + +
+
+
+ + { + const file = e.target.files?.[0] + if (!file) { + return + } + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = (event) => { + const dataURL = event.target?.result + showVideoSegment(dataURL as string) + e.target.value = '' + } + }} + /> + + ) +} + +export default VideoInsert diff --git a/src/components/chat_input/formats/emoji_blot.ts b/src/components/chat_input/formats/emoji_blot.ts new file mode 100644 index 0000000..47d1236 --- /dev/null +++ b/src/components/chat_input/formats/emoji_blot.ts @@ -0,0 +1,41 @@ +import Quill from 'quill' + +// eslint-disable-next-line +const Embed = Quill.import('blots/embed') as any +export interface EmojiValue { + alt: string + src: string + id: string +} +class EmojiBlot extends Embed { + static blotName: string = 'emoji' + static tagName: string = 'img' + static classNames: string[] = ['w-6', 'h-6'] + + static create(value: HTMLImageElement) { + const node = super.create(value) + node.setAttribute('alt', value.alt) + node.setAttribute('src', value.src) + node.setAttribute('data-id', value.id) + node.classList.add(...EmojiBlot.classNames) + return node + } + + static formats(node: HTMLImageElement): EmojiValue { + return { + alt: node.getAttribute('alt') ?? '', + src: node.getAttribute('src') ?? '', + id: node.getAttribute('data-id') ?? '' + } + } + + static value(node: HTMLImageElement): EmojiValue { + return { + alt: node.getAttribute('alt') ?? '', + src: node.getAttribute('src') ?? '', + id: node.getAttribute('data-id') ?? '' + } + } +} + +export default EmojiBlot diff --git a/src/components/chat_input/formats/image_blot.ts b/src/components/chat_input/formats/image_blot.ts new file mode 100644 index 0000000..182727a --- /dev/null +++ b/src/components/chat_input/formats/image_blot.ts @@ -0,0 +1,30 @@ +import Quill from 'quill' + +// eslint-disable-next-line +const Embed = Quill.import('blots/embed') as any +export interface ImageValue { + alt: string + src: string +} +class ImageBlot extends Embed { + static blotName = 'image' + static tagName = 'img' + static classNames: string[] = ['max-w-48', 'max-h-48', 'align-bottom'] + + static create(value: ImageValue) { + let node = super.create() + node.setAttribute('alt', value.alt) + node.setAttribute('src', value.src) + node.classList.add(...ImageBlot.classNames) + return node + } + + static value(node: HTMLImageElement): ImageValue { + return { + alt: node.getAttribute('alt') ?? '', + src: node.getAttribute('src') ?? '' + } + } +} + +export default ImageBlot diff --git a/src/components/chat_input/formats/reply_blot.ts b/src/components/chat_input/formats/reply_blot.ts new file mode 100644 index 0000000..8cf15f1 --- /dev/null +++ b/src/components/chat_input/formats/reply_blot.ts @@ -0,0 +1,43 @@ +import Quill from 'quill' + +// eslint-disable-next-line +const BlockEmbed = Quill.import('blots/block/embed') as any +export interface ReplyBlockValue { + messageId: string +} +class ReplyBlock extends BlockEmbed { + static blotName = 'reply' + static tagName = 'div' + static classNames = [ + 'p-2', + 'select-none', + 'bg-default-100', + 'rounded-md', + 'pointer-events-none' + ] + + static create(value: ReplyBlockValue) { + const node = super.create() + node.setAttribute('data-message-id', value.messageId) + node.setAttribute('contenteditable', 'false') + node.classList.add(...ReplyBlock.classNames) + const innerDom = document.createElement('div') + innerDom.classList.add('text-sm', 'text-default-500', 'relative') + const svgContainer = document.createElement('div') + svgContainer.classList.add('w-3', 'h-3', 'absolute', 'top-0', 'right-0') + const svg = ` ` + svgContainer.innerHTML = svg + innerDom.innerHTML = `消息ID:${value.messageId}` + innerDom.appendChild(svgContainer) + node.appendChild(innerDom) + return node + } + + static value(node: HTMLElement): ReplyBlockValue { + return { + messageId: node.getAttribute('data-message-id') || '' + } + } +} + +export default ReplyBlock diff --git a/src/components/chat_input/index.tsx b/src/components/chat_input/index.tsx new file mode 100644 index 0000000..01c294c --- /dev/null +++ b/src/components/chat_input/index.tsx @@ -0,0 +1,207 @@ +import { Button } from '@nextui-org/button' +import type { Range } from 'quill' +import 'quill/dist/quill.core.css' +import { useRef } from 'react' +import toast from 'react-hot-toast' +import { useQuill } from 'react-quilljs' + +import useShowStructuredMessage from '@/hooks/use_show_strcuted_message' + +import { quillToMessage } from '@/utils/onebot' + +import type { OB11Segment } from '@/types/onebot' + +import AudioInsert from './components/audio_insert' +import DiceInsert from './components/dice_insert' +import EmojiPicker from './components/emoji_picker' +import FileInsert from './components/file_insert' +import ImageInsert from './components/image_insert' +import MusicInsert from './components/music_insert' +import ReplyInsert from './components/reply_insert' +import RPSInsert from './components/rps_insert' +import VideoInsert from './components/video_insert' +import EmojiBlot from './formats/emoji_blot' +import type { EmojiValue } from './formats/emoji_blot' +import ImageBlot from './formats/image_blot' +import ReplyBlock from './formats/reply_blot' + +const ChatInput = () => { + const memorizedRange = useRef(null) + + const showStructuredMessage = useShowStructuredMessage() + const formats: string[] = ['image', 'emoji', 'reply'] + const modules = { + toolbar: '#toolbar' + } + const { quillRef, quill, Quill } = useQuill({ + modules, + formats, + placeholder: '请输入消息' + }) + + if (Quill && !quill) { + Quill.register('formats/emoji', EmojiBlot) + Quill.register('formats/image', ImageBlot, true) + Quill.register('formats/reply', ReplyBlock) + } + + if (quill) { + quill.on('selection-change', (range) => { + if (range) { + const editorContent = quill.getContents() + const firstOp = editorContent.ops[0] + + if ( + typeof firstOp?.insert !== 'string' && + firstOp?.insert?.reply && + range.index === 0 && + range.length !== quill.getLength() + ) { + quill.setSelection(1, Quill.sources.SILENT) + } + } + }) + + quill.on('text-change', () => { + const editorContent = quill.getContents() + const firstOp = editorContent.ops[0] + if ( + firstOp && + typeof firstOp.insert !== 'string' && + firstOp.insert?.reply && + quill.getLength() === 1 + ) { + quill.insertText(1, '\n', Quill.sources.SILENT) + } + }) + + quill.on('editor-change', (eventName: string) => { + if (eventName === 'text-change') { + const editorContent = quill.getContents() + const firstOp = editorContent.ops[0] + if ( + firstOp && + typeof firstOp.insert !== 'string' && + firstOp.insert?.reply && + quill.getLength() === 1 + ) { + quill.insertText(1, '\n', Quill.sources.SILENT) + } + } + }) + + quill.root.addEventListener('compositionstart', () => { + const editorContent = quill.getContents() + const firstOp = editorContent.ops[0] + if ( + firstOp && + typeof firstOp.insert !== 'string' && + firstOp.insert?.reply && + quill.getLength() === 1 + ) { + quill.insertText(1, '\n', Quill.sources.SILENT) + } + }) + } + + const onOpenChange = (open: boolean) => { + if (open) { + const selection = quill?.getSelection() + if (selection) memorizedRange.current = selection + } + } + + const insertImage = (url: string) => { + const selection = memorizedRange.current || quill?.getSelection() + quill?.deleteText(selection?.index || 0, selection?.length || 0) + quill?.insertEmbed(selection?.index || 0, 'image', { + src: url, + alt: '图片' + }) + quill?.setSelection((selection?.index || 0) + 1, 0) + } + function insertReplyBlock(messageId: string) { + const isNumberReg = /^(?:0|(?:-?[1-9]\d*))$/ + if (!isNumberReg.test(messageId)) { + toast.error('请输入正确的消息ID') + return + } + const editorContent = quill?.getContents() + const firstOp = editorContent?.ops[0] + const currentSelection = quill?.getSelection() + if ( + firstOp && + typeof firstOp.insert !== 'string' && + firstOp.insert?.reply + ) { + const delta = quill?.getContents() + if (delta) { + delta.ops[0] = { + insert: { reply: { messageId } } + } + quill?.setContents(delta, Quill.sources.USER) + } + } else { + quill?.insertEmbed(0, 'reply', { messageId }, Quill.sources.USER) + } + quill?.setSelection((currentSelection?.index || 0) + 1, 0) + quill?.blur() + } + const onInsertEmoji = (emoji: EmojiValue) => { + const selection = memorizedRange.current || quill?.getSelection() + quill?.deleteText(selection?.index || 0, selection?.length || 0) + quill?.insertEmbed(selection?.index || 0, 'emoji', { + alt: emoji.alt, + src: emoji.src, + id: emoji.id + }) + quill?.setSelection((selection?.index || 0) + 1, 0) + } + + const getChatMessage = () => { + const delta = quill?.getContents() + const ops = + delta?.ops?.filter((op) => { + return op.insert !== '\n' + }) ?? [] + const messages: OB11Segment[] = ops.map((op) => { + return quillToMessage(op) + }) + return messages + } + + return ( +
+
+
+ + + + + + + + + + +
+
+ ) +} + +export default ChatInput diff --git a/src/components/chat_input/modal.tsx b/src/components/chat_input/modal.tsx new file mode 100644 index 0000000..cb29712 --- /dev/null +++ b/src/components/chat_input/modal.tsx @@ -0,0 +1,49 @@ +import { Button } from '@nextui-org/button' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + useDisclosure +} from '@nextui-org/modal' + +import ChatInput from '.' + +export default function ChatInputModal() { + const { isOpen, onOpen, onOpenChange } = useDisclosure() + + return ( + <> + + + + {(onClose) => ( + <> + + 构造消息 + + +
+ +
+
+ + + + + )} +
+
+ + ) +} diff --git a/src/components/modal.tsx b/src/components/modal.tsx index a4f6f7e..4d6a51d 100644 --- a/src/components/modal.tsx +++ b/src/components/modal.tsx @@ -12,6 +12,7 @@ import React from 'react' export interface ModalProps { content: React.ReactNode title?: React.ReactNode + size?: React.ComponentProps['size'] onClose?: () => void onConfirm?: () => void onCancel?: () => void @@ -25,6 +26,7 @@ const Modal: React.FC = React.memo((props) => { backdrop = 'blur', title, content, + size = 'md', showCancel = true, dismissible, onClose, @@ -42,6 +44,7 @@ const Modal: React.FC = React.memo((props) => { onClose?.() onNativeClose() }} + size={size} classNames={{ backdrop: 'z-[99999999]', wrapper: 'z-[999999999]' diff --git a/src/components/onebot/api/debug.tsx b/src/components/onebot/api/debug.tsx index 1ef3c19..f700931 100644 --- a/src/components/onebot/api/debug.tsx +++ b/src/components/onebot/api/debug.tsx @@ -12,6 +12,7 @@ import { PiCatDuotone } from 'react-icons/pi' import key from '@/const/key' import { OneBotHttpApiContent, OneBotHttpApiPath } from '@/const/ob_api' +import ChatInputModal from '@/components/chat_input/modal' import CodeEditor from '@/components/code_editor' import PageLoading from '@/components/page_loading' @@ -147,10 +148,11 @@ const OneBotApiDebug: React.FC = (props) => { value={requestBody} onChange={(value) => setRequestBody(value ?? '')} language="json" - height="200px" + height="400px" />
+ = (props) => { {(onClose) => ( <> - 构造消息发送 + 构造请求
diff --git a/src/components/sidebar/index.tsx b/src/components/sidebar/index.tsx index 43cd34b..a6909b4 100644 --- a/src/components/sidebar/index.tsx +++ b/src/components/sidebar/index.tsx @@ -48,8 +48,8 @@ const SideBar: React.FC = (props) => { style={{ overflow: 'hidden' }} > -
- +
+
void } -interface ConfirmProps { +export interface ConfirmProps { title?: React.ReactNode content: React.ReactNode + size?: ModalProps['size'] dismissible?: boolean onConfirm?: () => void onCancel?: () => void } -interface ModalItem extends ModalProps { +export interface ModalItem extends ModalProps { id: number } @@ -40,7 +42,7 @@ export const DialogContext = React.createContext({ const DialogProvider: React.FC = ({ children }) => { const [dialogs, setDialogs] = React.useState([]) const alert = (config: AlertProps) => { - const { title, content, dismissible, onConfirm } = config + const { title, content, dismissible, onConfirm, size = 'md' } = config setDialogs((before) => { const id = before[before.length - 1]?.id + 1 || 0 @@ -50,13 +52,16 @@ const DialogProvider: React.FC = ({ children }) => { { id, content, + size, title, backdrop: 'blur', showCancel: false, dismissible: dismissible, onConfirm: () => { onConfirm?.() - setDialogs((before) => before.filter((item) => item.id !== id)) + setTimeout(() => { + setDialogs((before) => before.filter((item) => item.id !== id)) + }, 300) } } ] @@ -64,7 +69,14 @@ const DialogProvider: React.FC = ({ children }) => { } const confirm = (config: ConfirmProps) => { - const { title, content, dismissible, onConfirm, onCancel } = config + const { + title, + content, + dismissible, + onConfirm, + onCancel, + size = 'md' + } = config setDialogs((before) => { const id = before[before.length - 1]?.id + 1 || 0 @@ -74,6 +86,7 @@ const DialogProvider: React.FC = ({ children }) => { { id, content, + size, title, backdrop: 'blur', showCancel: true, diff --git a/src/hooks/use_show_strcuted_message.tsx b/src/hooks/use_show_strcuted_message.tsx new file mode 100644 index 0000000..affc55c --- /dev/null +++ b/src/hooks/use_show_strcuted_message.tsx @@ -0,0 +1,25 @@ +import { createElement } from 'react' + +import ShowStructedMessage from '@/components/chat_input/components/show_structed_message' + +import { OB11Segment } from '@/types/onebot' + +import useDialog from './use-dialog' + +const useShowStructuredMessage = () => { + const dialog = useDialog() + + const showStructuredMessage = (messages: OB11Segment[]) => { + dialog.alert({ + title: '消息内容', + size: '3xl', + content: createElement(ShowStructedMessage, { + messages: messages + }) + }) + } + + return showStructuredMessage +} + +export default useShowStructuredMessage diff --git a/src/main.tsx b/src/main.tsx index b5bfdcf..43effda 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,4 +1,3 @@ -import React from 'react' import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' @@ -23,11 +22,11 @@ if (theme && !theme.startsWith('"')) { } ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - + // + + + + + + // ) diff --git a/src/pages/dashboard/debug/websocket/index.tsx b/src/pages/dashboard/debug/websocket/index.tsx index 1a41dcd..e28de2f 100644 --- a/src/pages/dashboard/debug/websocket/index.tsx +++ b/src/pages/dashboard/debug/websocket/index.tsx @@ -7,6 +7,7 @@ import toast from 'react-hot-toast' import key from '@/const/key' +import ChatInputModal from '@/components/chat_input/modal' import OneBotMessageList from '@/components/onebot/message_list' import OneBotSendModal from '@/components/onebot/send_modal' import WSStatus from '@/components/onebot/ws_status' @@ -79,6 +80,7 @@ export default function WSDebug() { {FilterMessagesType}
+
diff --git a/src/pages/qq_login.tsx b/src/pages/qq_login.tsx index a14b726..9e049b2 100644 --- a/src/pages/qq_login.tsx +++ b/src/pages/qq_login.tsx @@ -119,7 +119,7 @@ export default function QQLoginPage() { maxYRotation={3} > -
+
logo
Web  diff --git a/src/pages/web_login.tsx b/src/pages/web_login.tsx index 22b6bf8..f1704ea 100644 --- a/src/pages/web_login.tsx +++ b/src/pages/web_login.tsx @@ -103,7 +103,7 @@ export default function WebLoginPage() { maxYRotation={3} > -
+
logo
Web  diff --git a/src/styles/globals.css b/src/styles/globals.css index 20db337..8c5773e 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -75,4 +75,23 @@ body { .monaco-editor .view-overlays .current-line-exact { border-color: rgba(0, 0, 0, 0.2) !important; border-radius: 5px !important; -} \ No newline at end of file +} + +.ql-hidden { + @apply hidden; +} +.ql-editor img { + @apply inline-block; +} +/* input.ql-image { + @apply hidden; +} +.ql-image svg { + fill: none; +} +.ql-fill { + fill: currentColor; +} +.ql-stroke { + stroke: currentColor; +} */ \ No newline at end of file diff --git a/src/types/onebot/segment.ts b/src/types/onebot/segment.ts index e071aec..e050b93 100644 --- a/src/types/onebot/segment.ts +++ b/src/types/onebot/segment.ts @@ -93,13 +93,11 @@ export interface AtSegment extends Segment { /** 猜拳魔法表情消息段 */ export interface RpsSegment extends Segment { type: 'rps' - data: object } /** 掷骰子魔法表情消息段 */ export interface DiceSegment extends Segment { type: 'dice' - data: object } /** 窗口抖动(戳一戳)消息段 */ @@ -168,7 +166,7 @@ export interface MusicSegment extends Segment { /** 音乐自定义分享消息段 */ export interface CustomMusicSegment extends Segment { - type: 'music_custom' + type: 'music' data: { type: 'custom' url: string diff --git a/src/utils/onebot.ts b/src/utils/onebot.ts index 7122d1a..d09a4e5 100644 --- a/src/utils/onebot.ts +++ b/src/utils/onebot.ts @@ -1,11 +1,18 @@ +import type { Op } from 'quill' + +import type { EmojiValue } from '@/components/chat_input/formats/emoji_blot' +import type { ImageValue } from '@/components/chat_input/formats/image_blot' +import type { ReplyBlockValue } from '@/components/chat_input/formats/reply_blot' + import { type AllOB11WsResponse, type OB11AllEvent, - OB11GroupMessage, - OB11Message, + type OB11GroupMessage, + type OB11Message, type OB11Notice, OB11NoticeType, - OB11PrivateMessage, + type OB11PrivateMessage, + type OB11Segment, type OneBot11Lifecycle, type RequestResponse } from '../types/onebot' @@ -199,3 +206,43 @@ export const isOB11GroupMessage = ( ): data is OB11GroupMessage => { return data.message_type === 'group' } + +/** + * 将 Quill Delta 转换为 OneBot 消息 + * @param op Quill Delta + * @returns OneBot 消息 + * @description 用于将 Quill Delta 转换为 OneBot 消息 + */ +export const quillToMessage = (op: Op) => { + let message: OB11Segment = { + type: 'text', + data: { + text: op.insert as string + } + } + if (typeof op.insert !== 'string') { + if (op.insert?.image) { + message = { + type: 'image', + data: { + file: (op.insert.image as ImageValue).src + } + } + } else if (op.insert?.emoji) { + message = { + type: 'face', + data: { + id: (op.insert.emoji as EmojiValue).id + } + } + } else if (op.insert?.reply) { + message = { + type: 'reply', + data: { + id: (op.insert.reply as ReplyBlockValue).messageId + } + } + } + } + return message +} diff --git a/src/utils/url.ts b/src/utils/url.ts index 335dca1..fc5c43f 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -47,3 +47,12 @@ export function parseAxiosResponse( : '' return `${statusLine}\r\n${headers}${body}` } + +/** + * 判断是否为URI + * @param uri URI + * @returns 是否为URI + */ +export const isURI = (uri: string) => { + return /^(http|https|file):\/\/.*/.test(uri) +} From 7b5255efb0bba855a0ba8277e6eb452dcbcdedfa Mon Sep 17 00:00:00 2001 From: bietiaop <1527109126@qq.com> Date: Thu, 2 Jan 2025 23:02:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(debug):=20=E4=BF=AE=E5=A4=8DOneBot?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=94=B9=E5=8F=98=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/onebot/render_message.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/components/onebot/render_message.tsx b/src/components/onebot/render_message.tsx index 2b728df..c6cd081 100644 --- a/src/components/onebot/render_message.tsx +++ b/src/components/onebot/render_message.tsx @@ -107,22 +107,23 @@ export const renderMessageContent = ( case 'location': return [位置: {segment.data.title || '未知'}] case 'music': + if (segment.data.type === 'custom') { + return ( + + {segment.data.title} + + ) + } return ( [音乐: {segment.data.type} - {segment.data.id}] ) - case 'music_custom': - return ( - - {segment.data.title} - - ) case 'reply': return (