mirror of
https://github.com/xiaoyiweb/YiAi.git
synced 2025-11-21 16:36:46 +08:00
初始化
This commit is contained in:
130
chat/src/components/base/Loading.vue
Normal file
130
chat/src/components/base/Loading.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
gap: 10,
|
||||
progress: 0,
|
||||
tips: '',
|
||||
words: ['L', 'O', 'A', 'D', 'I', 'N', 'G']
|
||||
})
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const theme = computed(() => appStore.theme)
|
||||
const loadingTextColor = computed(() => theme.value === 'dark' ? '#fff' : '#000')
|
||||
|
||||
interface Props {
|
||||
gap?: number
|
||||
progress?: number
|
||||
tips?: string
|
||||
bgColor?: string
|
||||
words?: any
|
||||
}
|
||||
// const words = ref<string[]>(['L', 'O', 'A', 'D', 'I', 'N', 'G'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loading" :style="{ background: props.bgColor }">
|
||||
<div class="loading-text">
|
||||
<span v-for="item in props.words" :key="item" :style="{ margin: `0 ${props.gap}px`, color: loadingTextColor }" class="loading-text-words">{{ item }}</span>
|
||||
</div>
|
||||
<div v-if="!tips && props.progress" class="progress">
|
||||
绘制进度: {{ props.progress }}%
|
||||
</div>
|
||||
<div v-if="tips" class="progress">
|
||||
{{ props.tips }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.progress{
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 110px;
|
||||
line-height: 100px;
|
||||
}
|
||||
.loading-text span {
|
||||
display: inline-block;
|
||||
margin: 0 5px;
|
||||
color: #fff;
|
||||
font-family: "Quattrocento Sans", sans-serif;
|
||||
}
|
||||
.loading-text span:nth-child(1) {
|
||||
filter: blur(0px);
|
||||
-webkit-animation: blur-text 1.5s 0s infinite linear alternate;
|
||||
animation: blur-text 1.5s 0s infinite linear alternate;
|
||||
}
|
||||
.loading-text span:nth-child(2) {
|
||||
filter: blur(0px);
|
||||
-webkit-animation: blur-text 1.5s 0.2s infinite linear alternate;
|
||||
animation: blur-text 1.5s 0.2s infinite linear alternate;
|
||||
}
|
||||
.loading-text span:nth-child(3) {
|
||||
filter: blur(0px);
|
||||
-webkit-animation: blur-text 1.5s 0.4s infinite linear alternate;
|
||||
animation: blur-text 1.5s 0.4s infinite linear alternate;
|
||||
}
|
||||
.loading-text span:nth-child(4) {
|
||||
filter: blur(0px);
|
||||
-webkit-animation: blur-text 1.5s 0.6s infinite linear alternate;
|
||||
animation: blur-text 1.5s 0.6s infinite linear alternate;
|
||||
}
|
||||
.loading-text span:nth-child(5) {
|
||||
filter: blur(0px);
|
||||
-webkit-animation: blur-text 1.5s 0.8s infinite linear alternate;
|
||||
animation: blur-text 1.5s 0.8s infinite linear alternate;
|
||||
}
|
||||
.loading-text span:nth-child(6) {
|
||||
filter: blur(0px);
|
||||
-webkit-animation: blur-text 1.5s 1s infinite linear alternate;
|
||||
animation: blur-text 1.5s 1s infinite linear alternate;
|
||||
}
|
||||
.loading-text span:nth-child(7) {
|
||||
filter: blur(0px);
|
||||
-webkit-animation: blur-text 1.5s 1.2s infinite linear alternate;
|
||||
animation: blur-text 1.5s 1.2s infinite linear alternate;
|
||||
}
|
||||
|
||||
@-webkit-keyframes blur-text {
|
||||
0% {
|
||||
filter: blur(0px);
|
||||
}
|
||||
100% {
|
||||
filter: blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blur-text {
|
||||
0% {
|
||||
filter: blur(0px);
|
||||
}
|
||||
100% {
|
||||
filter: blur(4px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
chat/src/components/base/index.ts
Normal file
3
chat/src/components/base/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import TitleBar from './titleBar.vue'
|
||||
|
||||
export { TitleBar }
|
||||
164
chat/src/components/base/macTablebar.vue
Normal file
164
chat/src/components/base/macTablebar.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useIpcRenderer } from '@vueuse/electron'
|
||||
defineProps<{ title?: string }>()
|
||||
import { useGlobalStore } from '@/store'
|
||||
|
||||
|
||||
const ipcRenderer = useIpcRenderer()
|
||||
const isFullScreen = ref(false)
|
||||
const globalStore = useGlobalStore()
|
||||
|
||||
const checkIfWindowIsMaximized = () => {
|
||||
ipcRenderer.send('check-window-maximized');
|
||||
};
|
||||
|
||||
const handleMaximizedStatus: any = (_: Event, isMaximized: any) => {
|
||||
isFullScreen.value = isMaximized
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
ipcRenderer.on('window-maximized-status', handleMaximizedStatus);
|
||||
ipcRenderer.on('clipboard-content', clipboardHandle);
|
||||
checkIfWindowIsMaximized();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
ipcRenderer.removeListener('window-maximized-status', handleMaximizedStatus);
|
||||
});
|
||||
|
||||
/* 关闭窗口 */
|
||||
const closeWindow = () => {
|
||||
ipcRenderer.invoke('closeWindow')
|
||||
}
|
||||
|
||||
/* 最大化最小化窗口 */
|
||||
const maxmizeMainWin = () => {
|
||||
ipcRenderer.invoke( isFullScreen.value ? 'unmaximizeWindow' : 'maxmizeWindow')
|
||||
isFullScreen.value = !isFullScreen.value
|
||||
}
|
||||
|
||||
/* 最小化窗口 */
|
||||
const minimizeMainWindow = () => {
|
||||
ipcRenderer.invoke('minimizeWindow')
|
||||
}
|
||||
|
||||
/* 处理粘贴内容 */
|
||||
const clipboardHandle = (event: any, content: any) => {
|
||||
globalStore.updateClipboardText(content)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrapper">
|
||||
<div class="btn close-btn" @click="closeWindow" />
|
||||
<div v-if="isFullScreen" class="btn disabled" />
|
||||
<div v-if="!isFullScreen" class="btn min-btn" @click="minimizeMainWindow" />
|
||||
<div class="btn max-btn" @click="maxmizeMainWin" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
margin-top: 8px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.btn:before,
|
||||
.btn:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 1px;
|
||||
opacity: 0;
|
||||
transition: all 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: #FF5D5B;
|
||||
border: 1px solid #CF544D;
|
||||
}
|
||||
|
||||
.min-btn {
|
||||
background: #FFBB39;
|
||||
border: 1px solid #CFA64E;
|
||||
}
|
||||
|
||||
.disabled{
|
||||
background: #cccccc;
|
||||
}
|
||||
|
||||
.max-btn {
|
||||
background: #00CD4E;
|
||||
border: 1px solid #0EA642;
|
||||
}
|
||||
|
||||
/* Close btn */
|
||||
.close-btn:before,
|
||||
.close-btn:after {
|
||||
width: 1px;
|
||||
height: 70%;
|
||||
background: #460100;
|
||||
}
|
||||
|
||||
.close-btn:before {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.close-btn:after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
/* min btn */
|
||||
.min-btn:before {
|
||||
width: 70%;
|
||||
height: 1px;
|
||||
background: #460100;
|
||||
}
|
||||
|
||||
/* max btn */
|
||||
.max-btn:before {
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
background: #024D0F;
|
||||
}
|
||||
|
||||
.max-btn:after {
|
||||
width: 1px;
|
||||
height: 90%;
|
||||
transform: translate(-50%, -50%) rotate(-135deg);
|
||||
background: #00CD4E;
|
||||
}
|
||||
|
||||
/* Hover function */
|
||||
.wrapper:hover .btn:before,
|
||||
.wrapper:hover .btn:after {
|
||||
top: 50%;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
38
chat/src/components/base/titleBar.vue
Normal file
38
chat/src/components/base/titleBar.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { PlayBack } from '@vicons/ionicons5'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
des?: string
|
||||
padding?: number
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
des: '',
|
||||
padding: 4,
|
||||
})
|
||||
const appStore = useAppStore()
|
||||
const darkMode = computed(() => appStore.theme === 'dark')
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex border-b border-[#ebebeb] dark:border-[#ffffff17] py-4 w-full" :class="[`px-${props.padding}`]">
|
||||
<div class="pt-1 mr-2 cursor-pointer">
|
||||
<NIcon size="16" class="text-primary" @click="router.push('/')">
|
||||
<PlayBack />
|
||||
</NIcon>
|
||||
</div>
|
||||
<div>
|
||||
<b :class="[darkMode ? 'text-[#fff]' : 'text-[#555]']" class="text-lg ">{{ props.title }}</b>
|
||||
<div :class="[darkMode ? 'text-[#fff]' : 'text-[#626569]']" class="text-truncate text-[#626569] mt-1">
|
||||
{{ props.des }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
235
chat/src/components/common/CanvasMask/index.vue
Normal file
235
chat/src/components/common/CanvasMask/index.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/* 图片地址 如果不是同源跨域 传入base64 */
|
||||
src: String,
|
||||
/* 图片 高度 宽度 不传就是用图片宽高、如果是缩略图 使用尺寸导出到原始尺寸 */
|
||||
width: Number,
|
||||
height: Number,
|
||||
/* 允许的画布最大宽度 限制区域 */
|
||||
max: {
|
||||
type: Number,
|
||||
default: 500,
|
||||
},
|
||||
/* 导出蒙版的底色背景色 */
|
||||
exportMaskBackgroundColor: {
|
||||
type: String,
|
||||
default: 'black',
|
||||
},
|
||||
/* 导出蒙版的绘制颜色 */
|
||||
exportMaskColor: {
|
||||
type: String,
|
||||
default: 'white',
|
||||
},
|
||||
penColor: {
|
||||
type: String,
|
||||
default: 'white',
|
||||
},
|
||||
penWidth: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
updateFileInfo: Function,
|
||||
})
|
||||
|
||||
// TODO 如果动态变更了线宽颜色等 在导出的时候没有记录每一步的线宽 而是使用了最后的
|
||||
|
||||
const canvas = ref<any>(null)
|
||||
const backgroundCanvas = ref<any>(null)
|
||||
const paths = ref<any>([])
|
||||
let isDrawing = false
|
||||
let currentPath: any = []
|
||||
const baseImage: any = new Image()
|
||||
const isEraserEnabled = ref(false)
|
||||
|
||||
const computedWidth = ref(0)
|
||||
const computedHeight = ref(0)
|
||||
const scaleRatio = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
const ctx: any = canvas.value.getContext('2d')
|
||||
const backgroundCtx = backgroundCanvas.value?.getContext('2d')
|
||||
baseImage.src = props.src
|
||||
baseImage.onload = () => {
|
||||
const ratio = Math.min(props.max / baseImage.width, props.max / baseImage.height)
|
||||
scaleRatio.value = ratio
|
||||
computedWidth.value = props.width || (ratio < 1 ? baseImage.width * ratio : baseImage.width)
|
||||
computedHeight.value = props.height || (ratio < 1 ? baseImage.height * ratio : baseImage.height)
|
||||
props.updateFileInfo?.({
|
||||
width: baseImage.width,
|
||||
height: baseImage.height,
|
||||
scaleRatio: ratio.toFixed(3),
|
||||
})
|
||||
canvas.value.width = computedWidth.value
|
||||
backgroundCanvas.value.width = computedWidth.value
|
||||
canvas.value.height = computedHeight.value
|
||||
backgroundCanvas.value.height = computedHeight.value
|
||||
// ctx.drawImage(baseImage, 0, 0, computedWidth.value, computedHeight.value);
|
||||
backgroundCtx.drawImage(baseImage, 0, 0, computedWidth.value, computedHeight.value)
|
||||
}
|
||||
canvas.value.addEventListener('mousedown', startDrawing)
|
||||
canvas.value.addEventListener('mousemove', draw)
|
||||
canvas.value.addEventListener('mouseup', stopDrawing)
|
||||
})
|
||||
|
||||
/* 开始绘制 */
|
||||
const startDrawing = (e: any) => {
|
||||
isDrawing = true
|
||||
const ctx = canvas.value.getContext('2d')
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(e.offsetX, e.offsetY)
|
||||
currentPath = [{ type: isEraserEnabled.value ? 'erase' : 'draw', x: e.offsetX, y: e.offsetY }]
|
||||
}
|
||||
|
||||
/* 绘制过程 */
|
||||
const draw = (e: any) => {
|
||||
if (!isDrawing)
|
||||
return
|
||||
const ctx = canvas.value.getContext('2d')
|
||||
ctx.lineTo(e.offsetX, e.offsetY)
|
||||
|
||||
if (isEraserEnabled.value) {
|
||||
// 橡皮擦模式:清除画布上的内容
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.lineWidth = props.penWidth * 2 // 橡皮擦宽度可以调整
|
||||
}
|
||||
else {
|
||||
// 正常绘制模式
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.strokeStyle = props.penColor
|
||||
ctx.lineWidth = props.penWidth
|
||||
}
|
||||
ctx.stroke()
|
||||
currentPath.push({ type: isEraserEnabled.value ? 'erase' : 'draw', x: e.offsetX, y: e.offsetY })
|
||||
}
|
||||
/* 完成单次绘制 */
|
||||
const stopDrawing = () => {
|
||||
isDrawing = false
|
||||
paths.value.push([...currentPath, { type: 'end' }])
|
||||
currentPath = []
|
||||
}
|
||||
|
||||
/* 获取Base图片 */
|
||||
const exportImage = (): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const exportCanvas = document.createElement('canvas')
|
||||
const image: any = baseImage
|
||||
exportCanvas.width = image.width
|
||||
exportCanvas.height = image.height
|
||||
const exportCtx = exportCanvas.getContext('2d')
|
||||
if (exportCtx) {
|
||||
exportCtx.fillStyle = props.exportMaskBackgroundColor
|
||||
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height)
|
||||
exportCtx.beginPath()
|
||||
const xRatio = image.width / computedWidth.value
|
||||
const yRatio = image.height / computedHeight.value
|
||||
exportCtx.beginPath()
|
||||
paths.value.forEach((pathArr: any[]) => {
|
||||
pathArr.forEach((path, index) => {
|
||||
if (path.type === 'begin' || path.type === 'draw') {
|
||||
if (index === 0 || pathArr[index - 1].type !== path.type)
|
||||
exportCtx.beginPath()
|
||||
|
||||
exportCtx.lineTo(path.x * xRatio, path.y * yRatio)
|
||||
exportCtx.strokeStyle = props.exportMaskColor
|
||||
exportCtx.lineWidth = props.penWidth * xRatio
|
||||
}
|
||||
if (path.type === 'erase') {
|
||||
if (index === 0 || pathArr[index - 1].type !== path.type)
|
||||
exportCtx.beginPath()
|
||||
|
||||
exportCtx.lineTo(path.x * xRatio, path.y * yRatio)
|
||||
exportCtx.strokeStyle = props.exportMaskBackgroundColor // 擦除路径使用的颜色(黑色)
|
||||
}
|
||||
// 每当一个 'draw' 或 'erase' 类型的路径结束时,结束当前的路径
|
||||
if (index < pathArr.length - 1 && pathArr[index + 1].type !== path.type)
|
||||
exportCtx.stroke()
|
||||
})
|
||||
// 如果最后一个路径元素是 'draw' 或 'erase',确保路径被结束
|
||||
if (pathArr[pathArr.length - 1].type !== 'begin')
|
||||
exportCtx.stroke()
|
||||
})
|
||||
const base64Image = exportCanvas.toDataURL('image/png')
|
||||
resolve(base64Image)
|
||||
}
|
||||
else {
|
||||
reject(new Error('无法获取canvas的2D渲染上下文'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 清空画布并重置 */
|
||||
function clear() {
|
||||
paths.value = []
|
||||
const ctx = canvas.value.getContext('2d')
|
||||
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
|
||||
}
|
||||
|
||||
/* 获取绘制后的蒙版图片 */
|
||||
async function getBase() {
|
||||
return await exportImage()
|
||||
}
|
||||
|
||||
/* 返回上一步 */
|
||||
function undo() {
|
||||
if (paths.value.length > 0) {
|
||||
paths.value.pop()
|
||||
redrawCanvas()
|
||||
}
|
||||
}
|
||||
|
||||
/* 重新绘制 */
|
||||
function redrawCanvas() {
|
||||
const ctx = canvas.value.getContext('2d')
|
||||
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
|
||||
ctx.drawImage(baseImage, 0, 0, computedWidth.value, computedHeight.value)
|
||||
|
||||
paths.value.forEach((pathArr: any[]) => {
|
||||
pathArr.forEach((path, index) => {
|
||||
if (index === 0 || pathArr[index - 1].type !== path.type)
|
||||
ctx.beginPath()
|
||||
|
||||
if (path.type === 'erase') {
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0)'
|
||||
}
|
||||
else {
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
ctx.strokeStyle = 'white'
|
||||
}
|
||||
ctx.lineWidth = path.type === 'erase' ? props.penWidth * 2 : props.penWidth
|
||||
ctx.lineTo(path.x, path.y)
|
||||
ctx.stroke()
|
||||
if (index === pathArr.length - 1 || pathArr[index + 1].type !== path.type)
|
||||
ctx.closePath()
|
||||
})
|
||||
})
|
||||
ctx.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
/* 切换橡皮擦模式 */
|
||||
const toggleEraser = () => {
|
||||
isEraserEnabled.value = !isEraserEnabled.value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getBase,
|
||||
undo,
|
||||
clear,
|
||||
toggleEraser,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full ">
|
||||
<canvas ref="backgroundCanvas" class="absolute left-0 top-0" :width="width" :height="height" />
|
||||
<canvas ref="canvas" class="absolute left-0 top-0" :width="width" :height="height" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
canvas {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
338
chat/src/components/common/GridManager/index.vue
Normal file
338
chat/src/components/common/GridManager/index.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, computed, onUnmounted, getCurrentInstance, watch, nextTick } from 'vue'
|
||||
import { throttle } from '@/utils/functions/throttle'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { copyText } from '@/utils/format'
|
||||
import { useMessage, NPopover } from 'naive-ui'
|
||||
import { useAuthStore } from '@/store'
|
||||
import { useRouter } from 'vue-router'
|
||||
const authStore = useAuthStore()
|
||||
interface Props {
|
||||
dataList: FileItem[]
|
||||
scaleWidth?: number
|
||||
isDrawLike?: boolean
|
||||
usePropmpt?: boolean
|
||||
copyPropmpt?: boolean
|
||||
gap?: number
|
||||
preOrigin?: boolean
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
width: number
|
||||
height: number
|
||||
cosUrl: string
|
||||
thumbImg: string
|
||||
size: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
id: number
|
||||
fileInfo: FileInfo
|
||||
prompt: string
|
||||
fullPrompt?: string
|
||||
originUrl?: string
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(ev: 'loadMore'): void
|
||||
(ev: 'usePropmptDraw', prompt: string): void
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(),{
|
||||
gap: 5,
|
||||
})
|
||||
const emit = defineEmits<Emit>()
|
||||
const $viewerApi = getCurrentInstance()?.appContext.config.globalProperties.$viewerApi
|
||||
const ms = useMessage()
|
||||
const boxRefs = ref<any>({})
|
||||
const otherInfoContainerHeight = ref(0)
|
||||
const realWidth = ref(160)
|
||||
const realColumn = ref(0)
|
||||
const loadComplete = ref<number[]>([])
|
||||
const wapperRef = ref<HTMLDivElement | null>(null)
|
||||
const wapperHeigth = ref(0)
|
||||
const isLogin = computed(() => authStore.isLogin)
|
||||
const width = computed(() => {
|
||||
return props.scaleWidth? Number(props.scaleWidth) * 2 + props.gap + 150 : 150
|
||||
});
|
||||
|
||||
const router = useRouter()
|
||||
/* 拿到图片高度 对定位top和right 新的一轮去插入最小值的那一列 贪心算法即可 */
|
||||
function compilerContainer() {
|
||||
calcHeight()
|
||||
compilerColumn()
|
||||
const columns = realColumn.value
|
||||
const itemWidth = realWidth.value
|
||||
const cacheHeight = <any>[]
|
||||
props.dataList.forEach((item, index) => {
|
||||
const { width, height } = item.fileInfo
|
||||
const bi = itemWidth / width
|
||||
const boxheight = height * bi + props.gap + otherInfoContainerHeight.value
|
||||
const currentBox = boxRefs.value[item.id]
|
||||
if (cacheHeight.length < columns) {
|
||||
currentBox.style.top = '0px'
|
||||
currentBox.style.left = `${(itemWidth + props.gap) * index}px`
|
||||
cacheHeight.push(boxheight)
|
||||
} else {
|
||||
const minHeight = Math.min.apply(null, cacheHeight)
|
||||
const minIndex = cacheHeight.findIndex((t: number) => t === minHeight)
|
||||
currentBox.style.top = `${minHeight + 0}px`
|
||||
currentBox.style.left = `${minIndex * (realWidth.value + props.gap)}px`
|
||||
cacheHeight[minIndex] += boxheight
|
||||
|
||||
}
|
||||
})
|
||||
wapperHeigth.value = Math.max(...cacheHeight) + 100
|
||||
}
|
||||
|
||||
function setItemRefs(el: HTMLDivElement, item: FileItem) {
|
||||
if (el && item) {
|
||||
boxRefs.value[item.id] = el;
|
||||
}
|
||||
}
|
||||
|
||||
/* 通过额外展示的信息计算有没有除了图片意外额外的高度 eg: 图片100px 额外显示其他信息30px cacheHeight的高度在图片的基础上需要+30 */
|
||||
function calcHeight() {
|
||||
const { showName = 0, showOther = 0 } = {}
|
||||
otherInfoContainerHeight.value = [showName, showOther].filter(t => t).length * 15
|
||||
}
|
||||
|
||||
|
||||
watch(() => props.scaleWidth, (val) => {
|
||||
handleResizeThrottled()
|
||||
})
|
||||
|
||||
watch(() => props.dataList, (val) => {
|
||||
if (!val) return;
|
||||
nextTick(() => {
|
||||
handleResizeThrottled()
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
/* 计算放多少列比较合理,并计算最终单个图片的宽 */
|
||||
function compilerColumn() {
|
||||
if (!wapperRef.value)
|
||||
return
|
||||
const containerWidth = wapperRef.value.clientWidth
|
||||
|
||||
/* 计算按目前宽度最多可以是几列 */
|
||||
realColumn.value = Math.floor(containerWidth / width.value)
|
||||
const surplus = containerWidth - realColumn.value * width.value // 剩下的多余空间
|
||||
/* 计算如果给了左右间距那么作业间距需要占多少宽度 */
|
||||
const positionWith = ((realColumn.value - 1) * props.gap) // 设置的right 需要padding的值
|
||||
/* 总宽度减去right的宽度,如果是负数考虑要不要cloumn-1 那么图片真实宽度就会比传入的宽度大 */
|
||||
if (surplus - positionWith < 0) {
|
||||
realColumn.value -= 1
|
||||
}
|
||||
/* 图片宽度*列 + right的间距 不管大于小于总宽 多的或者少的那部分都平分给列容器 保证总宽是100% */
|
||||
realWidth.value = Math.floor((containerWidth - positionWith) / realColumn.value)
|
||||
}
|
||||
|
||||
function imgLoadSuccess(e: any, item: FileItem) {
|
||||
loadComplete.value.push(item.id)
|
||||
}
|
||||
|
||||
function imgLoadError(e: any, item: FileItem) {
|
||||
loadComplete.value.push(item.id)
|
||||
}
|
||||
|
||||
function handleCopy(item: any) {
|
||||
if (!isLogin.value) {
|
||||
return authStore.setLoginDialog(true)
|
||||
}
|
||||
const { prompt } = item
|
||||
copyText({ text: prompt })
|
||||
ms.success('复制prompt成功')
|
||||
}
|
||||
|
||||
function drawLike(item: any) {
|
||||
router.push(`/midjourney?mjId=${item.id}`)
|
||||
}
|
||||
|
||||
|
||||
function usePropmptDraw(item: FileItem){
|
||||
const { prompt } = item
|
||||
emit('usePropmptDraw', prompt)
|
||||
}
|
||||
|
||||
|
||||
function handlePreview(item: any) {
|
||||
const { fileInfo } = item
|
||||
const { cosUrl } = fileInfo
|
||||
$viewerApi({ options: {}, images: [cosUrl] })
|
||||
}
|
||||
|
||||
const realHeight = computed(() => (item) => {
|
||||
const { fileInfo } = item
|
||||
const { width, height } = fileInfo
|
||||
return height / (width / realWidth.value)
|
||||
})
|
||||
|
||||
const handleResizeThrottled = throttle(function (this: any) {
|
||||
compilerContainer()
|
||||
}, 200)
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResizeThrottled)
|
||||
const container: any = document.getElementById('footer')
|
||||
const observer = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
emit('loadMore')
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe(container)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResizeThrottled)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class=" min-h-full overflow-hidden flex flex-col">
|
||||
<div class="flex-1 min-h-full p-4 relative">
|
||||
<div id="wapper" ref="wapperRef" class="wapper" :style="{ height: `${wapperHeigth}px` }">
|
||||
<div v-for="(item, index) in dataList" :id="item.id" :key="index" :ref="(el) => setItemRefs(el, item)"
|
||||
class="wapper-item" :style="{ width: `${realWidth}px` }">
|
||||
<transition name="img" :css="true">
|
||||
<img :id="item.id" class="item-file rounded-sm"
|
||||
:style="{ width: `${realWidth}px`, height: `${realHeight(item)}px` }" :src="preOrigin ? item.fileInfo.cosUrl : item.fileInfo.thumbImg"
|
||||
loading="lazy" @load="imgLoadSuccess($event, item)" @error="imgLoadError($event, item)"
|
||||
@click="handlePreview(item)" />
|
||||
</transition>
|
||||
<div class="menu p-2 text-[#cbd5e1]">
|
||||
<div class="prompt">
|
||||
{{ item.fullPrompt }}
|
||||
</div>
|
||||
<div class="flex justify-end items-end space-x-2">
|
||||
<n-popover trigger="hover" v-if="isDrawLike" >
|
||||
<template #trigger>
|
||||
<button
|
||||
class="flex h-5 w-8 items-center justify-center rounded border transition hover:bg-[#666161] dark:border-neutral-700 dark:hover:bg-[#33373c]"
|
||||
@click.stop="drawLike(item)">
|
||||
<span class="text-sm dark:text-slate-400">
|
||||
<SvgIcon icon="fluent:draw-image-24-regular" class="text-sm" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<span>画同款</span>
|
||||
</n-popover>
|
||||
|
||||
<n-popover trigger="hover" v-if="usePropmpt" >
|
||||
<template #trigger>
|
||||
<button
|
||||
class="flex h-5 w-8 items-center justify-center rounded border transition hover:bg-[#666161] dark:border-neutral-700 dark:hover:bg-[#33373c]"
|
||||
@click.stop="usePropmptDraw(item)">
|
||||
<span class="text-sm dark:text-slate-400">
|
||||
<SvgIcon icon="fluent:draw-image-24-regular" class="text-sm" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<span>使用当前画同款</span>
|
||||
</n-popover>
|
||||
|
||||
<n-popover trigger="hover" v-if="copyPropmpt">
|
||||
<template #trigger>
|
||||
<button
|
||||
class="flex h-5 w-8 items-center justify-center rounded border transition hover:bg-[#666161] dark:border-neutral-700 dark:hover:bg-[#33373c]"
|
||||
@click.stop="handleCopy(item)">
|
||||
<span class="text-sm dark:text-slate-400">
|
||||
<SvgIcon icon="tabler:copy" class="text-sm" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<span>复制提示词</span>
|
||||
</n-popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-loading" v-if="!loadComplete.includes(item.id)"
|
||||
:style="{ width: `${realWidth}px`, height: `${realHeight(item)}px` }"></div>
|
||||
</div>
|
||||
<div id="footer" class="w-full absolute bottom-[350px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
.market {
|
||||
}
|
||||
|
||||
.wapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding-bottom: 20px;
|
||||
|
||||
|
||||
&-item {
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
transition: all 0.5s;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
.menu {
|
||||
transition: transform 0.3s ease-in-out;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
img {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 94%;
|
||||
left: 3%;
|
||||
max-height: 70%;
|
||||
height: 100px;
|
||||
transform: translateY(100%);
|
||||
background-color: #090b15;
|
||||
opacity: 0.8;
|
||||
transition: all .1s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.prompt {
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
img {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
transition: all .6s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.item-loading {
|
||||
background: url(../../assets/img-bg.png) no-repeat center center;
|
||||
filter: blur(20px);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.img-enter-active,
|
||||
.img-leave-active {
|
||||
transition: transform .3s;
|
||||
}
|
||||
|
||||
.img-enter,
|
||||
.img-leave-to {
|
||||
transform: scale(.6);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
20
chat/src/components/common/HoverButton/Button.vue
Normal file
20
chat/src/components/common/HoverButton/Button.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang='ts'>
|
||||
interface Emit {
|
||||
(e: 'click'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
function handleClick() {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center justify-center w-10 h-8 transition rounded-md hover:bg-neutral-100 dark:hover:bg-[#414755]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
46
chat/src/components/common/HoverButton/index.vue
Normal file
46
chat/src/components/common/HoverButton/index.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed } from 'vue'
|
||||
import type { PopoverPlacement } from 'naive-ui'
|
||||
import { NTooltip } from 'naive-ui'
|
||||
import Button from './Button.vue'
|
||||
|
||||
interface Props {
|
||||
tooltip?: string
|
||||
placement?: PopoverPlacement
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(e: 'click'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tooltip: '',
|
||||
placement: 'bottom',
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
const showTooltip = computed(() => Boolean(props.tooltip))
|
||||
|
||||
function handleClick() {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showTooltip">
|
||||
<NTooltip :placement="placement" trigger="hover">
|
||||
<template #trigger>
|
||||
<Button @click="handleClick">
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
{{ tooltip }}
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Button @click="handleClick">
|
||||
<slot />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
217
chat/src/components/common/ImageEditorCanvas/index.vue
Normal file
217
chat/src/components/common/ImageEditorCanvas/index.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div>
|
||||
<canvas ref="canvas" @click="handleClick" crossOrigin="anonymous"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
// 定义接收的属性
|
||||
const props = defineProps({
|
||||
src: String,
|
||||
selectColor: String,
|
||||
maxSteps: Number,
|
||||
updateFileInfo: Function
|
||||
});
|
||||
|
||||
const canvas = ref<HTMLCanvasElement | null>(null);
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null);
|
||||
let modifiedPixels = new Set<string>();
|
||||
const history = ref<ImageData[]>([]);
|
||||
const maxHistorySteps = ref(10)
|
||||
|
||||
watch(() => props.maxSteps, (val) => {
|
||||
val && (maxHistorySteps.value = val)
|
||||
}, { immediate: true })
|
||||
// 初始化canvas
|
||||
onMounted(() => {
|
||||
if (canvas.value) {
|
||||
ctx.value = canvas.value.getContext('2d', { willReadFrequently: true });
|
||||
initCanvas();
|
||||
}
|
||||
});
|
||||
|
||||
// 监听src属性变化
|
||||
watch(() => props.src, (newSrc) => {
|
||||
if (newSrc) {
|
||||
initCanvas();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化Canvas函数
|
||||
function initCanvas(){
|
||||
if (!ctx.value || !props.src) return;
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
canvas.value!.width = img.width;
|
||||
canvas.value!.height = img.height;
|
||||
props.updateFileInfo?.({
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
scaleRatio: 1,
|
||||
})
|
||||
ctx.value!.drawImage(img, 0, 0, img.width, img.height);
|
||||
};
|
||||
img.src = props.src;
|
||||
};
|
||||
|
||||
// 获取下标
|
||||
function pointToIndex (x: number, y: number) {
|
||||
return (y * canvas.value!.width + x) * 4;
|
||||
};
|
||||
|
||||
// 获取颜色
|
||||
function getColor(x: number, y: number, imgData: Uint8ClampedArray) {
|
||||
const i = pointToIndex(x, y);
|
||||
return [
|
||||
imgData[i],
|
||||
imgData[i + 1],
|
||||
imgData[i + 2],
|
||||
imgData[i + 3],
|
||||
];
|
||||
};
|
||||
|
||||
function diff (color1: number[], color2: number[]) {
|
||||
const sum = color1.reduce((sum, value, index) => sum + Math.abs(value - color2[index]), 0);
|
||||
return sum;
|
||||
};
|
||||
|
||||
// 修改颜色
|
||||
function changeColor (initX: number, initY: number, targetColor: number[], clickColor: number[], imgData: Uint8ClampedArray){
|
||||
// 保存当前状态
|
||||
if (ctx.value && canvas.value) {
|
||||
const currentImageData = ctx.value.getImageData(0, 0, canvas.value.width, canvas.value.height);
|
||||
addAction(currentImageData)
|
||||
}
|
||||
const queue = [[initX, initY]];
|
||||
while(queue.length) {
|
||||
const [x, y] = queue.shift()!;
|
||||
if(x < 0 || x >= canvas.value!.width || y < 0 || y >= canvas.value!.height) continue;
|
||||
const curColor = getColor(x, y, imgData);
|
||||
if(diff(curColor, clickColor) > 50) continue;
|
||||
if(diff(curColor, targetColor) === 0) continue;
|
||||
const i = pointToIndex(x, y);
|
||||
imgData.set(targetColor, i);
|
||||
modifiedPixels.add(x + "," + y);
|
||||
queue.push([x+1, y]);
|
||||
queue.push([x-1, y]);
|
||||
queue.push([x, y+1]);
|
||||
queue.push([x, y-1]);
|
||||
}
|
||||
};
|
||||
|
||||
/* 点选图片换色 */
|
||||
function handleClick(e: MouseEvent){
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
const x = e.offsetX;
|
||||
const y = e.offsetY;
|
||||
const imageData = ctx.value.getImageData(0, 0, canvas.value.width, canvas.value.height);
|
||||
const clickColor = getColor(x, y, imageData.data);
|
||||
const color = parseColor(props.selectColor);
|
||||
changeColor(x, y, color, clickColor, imageData.data);
|
||||
ctx.value.putImageData(imageData, 0, 0);
|
||||
};
|
||||
|
||||
/* 获得base64 */
|
||||
function exportToBase64WithCustomBackground() {
|
||||
if (!ctx.value || !canvas.value) return '';
|
||||
const originalImageData = ctx.value.getImageData(0, 0, canvas.value.width, canvas.value.height);
|
||||
const copiedData = new Uint8ClampedArray(originalImageData.data);
|
||||
for (let y = 0; y < canvas.value.height; y++) {
|
||||
for (let x = 0; x < canvas.value.width; x++) {
|
||||
const i = pointToIndex(x, y);
|
||||
if (modifiedPixels.has(x + "," + y)) {
|
||||
copiedData[i] = 255;
|
||||
copiedData[i + 1] = 255;
|
||||
copiedData[i + 2] = 255;
|
||||
} else {
|
||||
copiedData[i] = 0;
|
||||
copiedData[i + 1] = 0;
|
||||
copiedData[i + 2] = 0;
|
||||
}
|
||||
copiedData[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
const newImageData = new ImageData(copiedData, canvas.value.width, canvas.value.height);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = canvas.value.width;
|
||||
tempCanvas.height = canvas.value.height;
|
||||
const tempCtx = tempCanvas.getContext('2d')!;
|
||||
tempCtx.putImageData(newImageData, 0, 0);
|
||||
return tempCanvas.toDataURL("image/png");
|
||||
};
|
||||
/* 格式化传入颜色 */
|
||||
function parseColor(selectColor: string): number[]{
|
||||
if (selectColor && selectColor.startsWith('#')) {
|
||||
const extendedHex = selectColor.length === 4 ? '#' + selectColor[1] + selectColor[1] + selectColor[2] + selectColor[2] + selectColor[3] + selectColor[3] : selectColor;
|
||||
const r = parseInt(extendedHex.slice(1, 3), 16);
|
||||
const g = parseInt(extendedHex.slice(3, 5), 16);
|
||||
const b = parseInt(extendedHex.slice(5, 7), 16);
|
||||
return [r, g, b, 255];
|
||||
}
|
||||
else if (selectColor && selectColor.startsWith('rgb')) {
|
||||
const rgbValues = selectColor
|
||||
.replace(/rgba?\(/, '')
|
||||
.replace(/\)/, '')
|
||||
.split(',')
|
||||
.map((num) => parseInt(num));
|
||||
if (rgbValues.length === 3) rgbValues.push(255); // 如果没有 alpha,添加一个默认的不透明度
|
||||
return rgbValues;
|
||||
}
|
||||
return [0, 0, 0, 255];
|
||||
};
|
||||
/* 对外提供base64格式的遮罩 */
|
||||
async function getBase(){
|
||||
return await exportToBase64WithCustomBackground()
|
||||
}
|
||||
/* 返回上一步 */
|
||||
function undo() {
|
||||
if(history.value.length === 0 || !ctx.value || !canvas.value) return;
|
||||
const previousState = history.value.pop();
|
||||
ctx.value.putImageData(previousState.imageData, 0, 0);
|
||||
modifiedPixels = new Set(previousState.currentModifiedPixels);
|
||||
}
|
||||
/* 清空画布 */
|
||||
function clear() {
|
||||
if (!ctx.value || !canvas.value) return;
|
||||
|
||||
// 直接清空画布
|
||||
ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
|
||||
|
||||
// 重置修改记录和历史记录
|
||||
modifiedPixels.clear();
|
||||
history.value = []; // 如果您想保留初始状态,可以重置为 [initialState]
|
||||
|
||||
initCanvas();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* 设置记录栈最大存储步数 防止存储过多 */
|
||||
function setMaxHistorySteps(steps) {
|
||||
maxHistorySteps.value = steps;
|
||||
}
|
||||
|
||||
/* 检测、超出限制移除老的数据 */
|
||||
function addAction(imageData) {
|
||||
const currentModifiedPixels = new Set(modifiedPixels); // 创建 modifiedPixels 的一个副本
|
||||
history.value.push({ imageData, currentModifiedPixels }); // 保存 imageData 和 modifiedPixels
|
||||
if (history.value.length > maxHistorySteps.value) {
|
||||
history.value.shift();
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getBase,
|
||||
undo,
|
||||
clear
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 这里可以添加样式 */
|
||||
</style>
|
||||
43
chat/src/components/common/NaiveProvider/index.vue
Normal file
43
chat/src/components/common/NaiveProvider/index.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { defineComponent, h } from 'vue'
|
||||
import {
|
||||
NDialogProvider,
|
||||
NLoadingBarProvider,
|
||||
NMessageProvider,
|
||||
NNotificationProvider,
|
||||
useDialog,
|
||||
useLoadingBar,
|
||||
useMessage,
|
||||
useNotification,
|
||||
} from 'naive-ui'
|
||||
|
||||
function registerNaiveTools() {
|
||||
window.$loadingBar = useLoadingBar()
|
||||
window.$dialog = useDialog()
|
||||
window.$message = useMessage()
|
||||
window.$notification = useNotification()
|
||||
}
|
||||
|
||||
const NaiveProviderContent = defineComponent({
|
||||
name: 'NaiveProviderContent',
|
||||
setup() {
|
||||
registerNaiveTools()
|
||||
},
|
||||
render() {
|
||||
return h('div')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLoadingBarProvider>
|
||||
<NDialogProvider>
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<slot />
|
||||
<NaiveProviderContent />
|
||||
</NMessageProvider>
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NLoadingBarProvider>
|
||||
</template>
|
||||
73
chat/src/components/common/QRCode/index.vue
Normal file
73
chat/src/components/common/QRCode/index.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { useQRCode } from '@vueuse/integrations/useQRCode'
|
||||
/*
|
||||
参考文档:https://vueuse.org/integrations/useQRCode/
|
||||
https://www.npmjs.com/package/qrcode#qr-code-options
|
||||
*/
|
||||
interface Props {
|
||||
value?: string // 扫描后的文本或地址
|
||||
size?: number // 二维码大小
|
||||
color?: string // 二维码颜色,Value must be in hex format (十六进制颜色值)
|
||||
backgroundColor?: string // 二维码背景色,Value must be in hex format (十六进制颜色值)
|
||||
bordered?: boolean // 是否有边框
|
||||
borderColor?: string // 边框颜色
|
||||
scale?: number // 每个black dots多少像素
|
||||
/*
|
||||
纠错等级也叫纠错率,就是指二维码可以被遮挡后还能正常扫描,而这个能被遮挡的最大面积就是纠错率。
|
||||
通常情况下二维码分为 4 个纠错级别:L级 可纠正约 7% 错误、M级 可纠正约 15% 错误、Q级 可纠正约 25% 错误、H级 可纠正约30% 错误。
|
||||
并不是所有位置都可以缺损,像最明显的三个角上的方框,直接影响初始定位。中间零散的部分是内容编码,可以容忍缺损。
|
||||
当二维码的内容编码携带信息比较少的时候,也就是链接比较短的时候,设置不同的纠错等级,生成的图片不会发生变化。
|
||||
*/
|
||||
errorLevel?: string // 二维码纠错等级
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: '',
|
||||
size: 160,
|
||||
color: '#000',
|
||||
backgroundColor: '#FFF',
|
||||
bordered: true,
|
||||
borderColor: '#0505050f',
|
||||
scale: 8,
|
||||
errorLevel: 'H', // 可选 L M Q H
|
||||
})
|
||||
|
||||
// `qrcode` will be a ref of data URL
|
||||
const qrcode = useQRCode(props.value, {
|
||||
errorCorrectionLevel: props.errorLevel,
|
||||
type: 'image/png',
|
||||
quality: 1,
|
||||
margin: 3,
|
||||
scale: props.scale, // 8px per modules(black dots)
|
||||
color: {
|
||||
dark: props.color, // 像素点颜色
|
||||
light: props.backgroundColor, // 背景色
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="m-qrcode" :class="{ bordered }" :style="`width: ${size}px; height: ${size}px; border-color: ${borderColor};`">
|
||||
<img :src="qrcode" class="u-qrcode" alt="QRCode">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.m-qrcode {
|
||||
display: inline-block;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
.u-qrcode {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.bordered {
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
</style>
|
||||
46
chat/src/components/common/Setting/Advanced.vue
Normal file
46
chat/src/components/common/Setting/Advanced.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { NButton, NInput, useMessage } from 'naive-ui'
|
||||
import { useSettingStore } from '@/store'
|
||||
import type { SettingsState } from '@/store/modules/settings/helper'
|
||||
import { t } from '@/locales'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const ms = useMessage()
|
||||
|
||||
const systemMessage = ref(settingStore.systemMessage ?? '')
|
||||
|
||||
function updateSettings(options: Partial<SettingsState>) {
|
||||
settingStore.updateSetting(options)
|
||||
ms.success(t('common.success'))
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
settingStore.resetSetting()
|
||||
ms.success(t('common.success'))
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 space-y-5 min-h-[200px]">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">{{ $t('setting.role') }}</span>
|
||||
<div class="flex-1">
|
||||
<NInput v-model:value="systemMessage" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" />
|
||||
</div>
|
||||
<NButton size="tiny" text type="primary" @click="updateSettings({ systemMessage })">
|
||||
{{ $t('common.save') }}
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]"> </span>
|
||||
<NButton size="small" @click="handleReset">
|
||||
{{ $t('common.reset') }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
213
chat/src/components/common/Setting/General.vue
Normal file
213
chat/src/components/common/Setting/General.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { NButton, NInput, NPopconfirm, NSelect, useMessage } from 'naive-ui'
|
||||
import type { Language, Theme } from '@/store/modules/app/helper'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
import { useAppStore, useAuthStore } from '@/store'
|
||||
import { getCurrentDate } from '@/utils/functions'
|
||||
import { useBasicLayout } from '@/hooks/useBasicLayout'
|
||||
import { t } from '@/locales'
|
||||
import { fetchUpdateInfoAPI } from '@/api/index'
|
||||
import type { ResData } from '@/api/types'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const { isMobile } = useBasicLayout()
|
||||
|
||||
const ms = useMessage()
|
||||
|
||||
const theme = computed(() => appStore.theme)
|
||||
|
||||
const userInfo = computed(() => authStore.userInfo)
|
||||
|
||||
const avatar = ref(userInfo.value.avatar ?? '')
|
||||
|
||||
const username = ref(userInfo.value.username ?? '')
|
||||
|
||||
const btnDisabled = ref(false)
|
||||
|
||||
const language = computed({
|
||||
get() {
|
||||
return appStore.language
|
||||
},
|
||||
set(value: Language) {
|
||||
appStore.setLanguage(value)
|
||||
},
|
||||
})
|
||||
|
||||
const themeOptions: { label: string; key: Theme; icon: string }[] = [
|
||||
{
|
||||
label: 'Auto',
|
||||
key: 'auto',
|
||||
icon: 'ri:contrast-line',
|
||||
},
|
||||
{
|
||||
label: 'Light',
|
||||
key: 'light',
|
||||
icon: 'ri:sun-foggy-line',
|
||||
},
|
||||
{
|
||||
label: 'Dark',
|
||||
key: 'dark',
|
||||
icon: 'ri:moon-foggy-line',
|
||||
},
|
||||
]
|
||||
|
||||
const languageOptions: { label: string; key: Language; value: Language }[] = [
|
||||
{ label: '简体中文', key: 'zh-CN', value: 'zh-CN' },
|
||||
// { label: '繁體中文', key: 'zh-TW', value: 'zh-TW' },
|
||||
// { label: 'English', key: 'en-US', value: 'en-US' },
|
||||
]
|
||||
|
||||
async function updateUserInfo(options: { avatar?: string; username?: string }) {
|
||||
try {
|
||||
btnDisabled.value = true
|
||||
const res: ResData = await fetchUpdateInfoAPI(options)
|
||||
btnDisabled.value = false
|
||||
if (!res.success)
|
||||
return ms.error(res.message)
|
||||
ms.success(t('common.updateUserSuccess'))
|
||||
authStore.getUserInfo()
|
||||
}
|
||||
catch (error) {
|
||||
btnDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportData(): void {
|
||||
const date = getCurrentDate()
|
||||
const data: string = localStorage.getItem('chatStorage') || '{}'
|
||||
const jsonString: string = JSON.stringify(JSON.parse(data), null, 2)
|
||||
const blob: Blob = new Blob([jsonString], { type: 'application/json' })
|
||||
const url: string = URL.createObjectURL(blob)
|
||||
const link: HTMLAnchorElement = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `chat-store_${date}.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
function importData(event: Event): void {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target || !target.files)
|
||||
return
|
||||
|
||||
const file: File = target.files[0]
|
||||
if (!file)
|
||||
return
|
||||
|
||||
const reader: FileReader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(reader.result as string)
|
||||
localStorage.setItem('chatStorage', JSON.stringify(data))
|
||||
ms.success(t('common.success'))
|
||||
location.reload()
|
||||
}
|
||||
catch (error) {
|
||||
ms.error(t('common.invalidFileFormat'))
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
function clearData(): void {
|
||||
localStorage.removeItem('chatStorage')
|
||||
location.reload()
|
||||
}
|
||||
|
||||
function handleImportButtonClick(): void {
|
||||
const fileInput = document.getElementById('fileInput') as HTMLElement
|
||||
if (fileInput)
|
||||
fileInput.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 space-y-5 min-h-[200px]">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">{{ $t('setting.avatarLink') }}</span>
|
||||
<div class="flex-1">
|
||||
<NInput v-model:value="avatar" placeholder="请填写头像地址" />
|
||||
</div>
|
||||
<NButton size="tiny" :disabled="btnDisabled" text type="primary" @click="updateUserInfo({ avatar })">
|
||||
{{ $t('common.update') }}
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">{{ $t('setting.name') }}</span>
|
||||
<div class="w-[200px]">
|
||||
<NInput v-model:value="username" placeholder="请填写用户名" />
|
||||
</div>
|
||||
<NButton size="tiny" :disabled="btnDisabled" text type="primary" @click="updateUserInfo({ username })">
|
||||
{{ $t('common.update') }}
|
||||
</NButton>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center space-x-4"
|
||||
:class="isMobile && 'items-start'"
|
||||
>
|
||||
<span class="flex-shrink-0 w-[100px]">{{ $t('setting.chatHistory') }}</span>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<NButton size="small" @click="exportData">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ri:download-2-fill" />
|
||||
</template>
|
||||
{{ $t('common.export') }}
|
||||
</NButton>
|
||||
|
||||
<input id="fileInput" type="file" style="display:none" @change="importData">
|
||||
<NButton size="small" @click="handleImportButtonClick">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ri:upload-2-fill" />
|
||||
</template>
|
||||
{{ $t('common.import') }}
|
||||
</NButton>
|
||||
|
||||
<NPopconfirm placement="bottom" @positive-click="clearData">
|
||||
<template #trigger>
|
||||
<NButton size="small">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ri:close-circle-line" />
|
||||
</template>
|
||||
{{ $t('common.clear') }}
|
||||
</NButton>
|
||||
</template>
|
||||
{{ $t('chat.clearHistoryConfirm') }}
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">{{ $t('setting.theme') }}</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<template v-for="item of themeOptions" :key="item.key">
|
||||
<NButton
|
||||
size="small"
|
||||
:type="item.key === theme ? 'primary' : undefined"
|
||||
@click="appStore.setTheme(item.key)"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon :icon="item.icon" />
|
||||
</template>
|
||||
</NButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">{{ $t('setting.language') }}</span>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<NSelect
|
||||
style="width: 140px"
|
||||
:value="language"
|
||||
:options="languageOptions"
|
||||
@update-value="value => appStore.setLanguage(value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
66
chat/src/components/common/Setting/Personal.vue
Normal file
66
chat/src/components/common/Setting/Personal.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang='ts'>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NSpin } from 'naive-ui'
|
||||
import { useAuthStore } from '@/store'
|
||||
const authStore = useAuthStore()
|
||||
const { userInfo, userBalance } = authStore
|
||||
|
||||
const loading = ref(false)
|
||||
onMounted(async () => {
|
||||
getInfo()
|
||||
})
|
||||
|
||||
async function getInfo() {
|
||||
try {
|
||||
loading.value = true
|
||||
await authStore.getUserInfo()
|
||||
loading.value = false
|
||||
}
|
||||
catch (error) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpin :show="loading">
|
||||
<div class="p-4 space-y-5 min-h-[200px]">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">用户邮箱</span>
|
||||
<div class="w-[200px]">
|
||||
{{ userInfo.email || "--" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">用户姓名</span>
|
||||
<div class="w-[200px]">
|
||||
{{ userInfo.username || "--" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">问答余额</span>
|
||||
<div class="w-[200px]">
|
||||
{{ userBalance.usesLeft || "0" }} 积分
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">绘画余额</span>
|
||||
<div class="w-[200px]">
|
||||
{{ userBalance.paintCount || "0" }} 积分
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">MJToken</span>
|
||||
<div class="w-[200px]">
|
||||
{{ userBalance.balance || "0" }} Token
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="flex-shrink-0 w-[100px]">使用金额</span>
|
||||
<div class="w-[200px]">
|
||||
{{ userBalance.useTokens || "0" }} Token
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NSpin>
|
||||
</template>
|
||||
64
chat/src/components/common/Setting/index.vue
Normal file
64
chat/src/components/common/Setting/index.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, ref } from 'vue'
|
||||
import { NModal, NTabPane, NTabs } from 'naive-ui'
|
||||
import General from './General.vue'
|
||||
import Personal from './Personal.vue'
|
||||
import { SvgIcon } from '@/components/common'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
interface Emit {
|
||||
(e: 'update:visible', visible: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<Emit>()
|
||||
|
||||
const active = ref('personalInfo')
|
||||
|
||||
const show = computed({
|
||||
get() {
|
||||
return props.visible
|
||||
},
|
||||
set(visible: boolean) {
|
||||
emit('update:visible', visible)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal v-model:show="show" title="个人中心" :auto-focus="false" preset="card" style="width: 95%; max-width: 640px">
|
||||
<div>
|
||||
<NTabs v-model:value="active" type="line" animated>
|
||||
<NTabPane name="personalInfo" tab="personalInfo">
|
||||
<template #tab>
|
||||
<SvgIcon class="text-lg" icon="ri:file-user-line" />
|
||||
<span class="ml-2">{{ $t('setting.personalInfo') }}</span>
|
||||
</template>
|
||||
<Personal />
|
||||
</NTabPane>
|
||||
<NTabPane name="General" tab="General">
|
||||
<template #tab>
|
||||
<SvgIcon class="text-lg" icon="ri:list-settings-line" />
|
||||
<span class="ml-2">{{ $t('setting.general') }}</span>
|
||||
</template>
|
||||
<div class="min-h-[100px]">
|
||||
<General />
|
||||
</div>
|
||||
</NTabPane>
|
||||
<!-- <NTabPane name="Advanced" tab="Advanced">
|
||||
<template #tab>
|
||||
<SvgIcon class="text-lg" icon="ri:equalizer-line" />
|
||||
<span class="ml-2">{{ $t('setting.advanced') }}</span>
|
||||
</template>
|
||||
<div class="min-h-[100px]">
|
||||
<Advanced />
|
||||
</div>
|
||||
</NTabPane> -->
|
||||
</NTabs>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
21
chat/src/components/common/SvgIcon/index.vue
Normal file
21
chat/src/components/common/SvgIcon/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
interface Props {
|
||||
icon?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||
class: (attrs.class as string) || '',
|
||||
style: (attrs.style as string) || 'width: 1em, height: 1em',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon :icon="icon" v-bind="bindAttrs" />
|
||||
</template>
|
||||
52
chat/src/components/common/UserAvatar/index.vue
Normal file
52
chat/src/components/common/UserAvatar/index.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang='ts'>
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { NAvatar, NButton } from 'naive-ui'
|
||||
import { useAuthStore } from '@/store'
|
||||
import defaultAvatar from '@/assets/avatar.png'
|
||||
import { isString } from '@/utils/is'
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const Setting = defineAsyncComponent(() => import('@/components/common/Setting/index.vue'))
|
||||
const userInfo = computed(() => authStore.userInfo)
|
||||
const loginComplete = computed(() => authStore.token)
|
||||
const show = ref(false)
|
||||
|
||||
function openDialog() {
|
||||
if (!loginComplete.value)
|
||||
authStore.setLoginDialog(true)
|
||||
else
|
||||
show.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center overflow-hidden">
|
||||
<div class="w-10 h-10 overflow-hidden rounded-full shrink-0">
|
||||
<template v-if="isString(userInfo.avatar) && userInfo.avatar.length > 0">
|
||||
<NAvatar
|
||||
class="cursor-pointer"
|
||||
size="large"
|
||||
round
|
||||
:src="userInfo.avatar"
|
||||
:fallback-src="defaultAvatar"
|
||||
@click="openDialog"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NAvatar size="large" round :src="defaultAvatar" @click="openDialog" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 ml-2">
|
||||
<h2 v-if="loginComplete" class="overflow-hidden font-bold text-md text-ellipsis whitespace-nowrap cursor-pointer" @click="show = true">
|
||||
{{ userInfo.username ?? '未登录' }}
|
||||
</h2>
|
||||
<NButton v-if="!loginComplete" text @click="authStore.setLoginDialog(true)">
|
||||
登录/注册
|
||||
</NButton>
|
||||
<!-- <p class="overflow-hidden text-xs text-gray-500 text-ellipsis whitespace-nowrap">
|
||||
<span> 点击购买卡密</span>
|
||||
</p> -->
|
||||
</div>
|
||||
<Setting v-if="show" v-model:visible="show" />
|
||||
</div>
|
||||
</template>
|
||||
7
chat/src/components/common/index.ts
Normal file
7
chat/src/components/common/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import HoverButton from './HoverButton/index.vue'
|
||||
import NaiveProvider from './NaiveProvider/index.vue'
|
||||
import SvgIcon from './SvgIcon/index.vue'
|
||||
import UserAvatar from './UserAvatar/index.vue'
|
||||
import Setting from './Setting/index.vue'
|
||||
|
||||
export { HoverButton, NaiveProvider, SvgIcon, UserAvatar, Setting }
|
||||
Reference in New Issue
Block a user