初始化

This commit is contained in:
xiaoyi
2024-01-27 19:53:17 +08:00
commit 07dbe71c31
840 changed files with 119152 additions and 0 deletions

View 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>

View File

@@ -0,0 +1,3 @@
import TitleBar from './titleBar.vue'
export { TitleBar }

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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]">&nbsp;</span>
<NButton size="small" @click="handleReset">
{{ $t('common.reset') }}
</NButton>
</div>
</div>
</div>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 }