🎨 更新用户版本

This commit is contained in:
2025-09-02 22:56:30 +08:00
parent 8276b3e87f
commit a1906d2a36
71 changed files with 4790 additions and 962 deletions

View File

@@ -0,0 +1,39 @@
<template>
<error-preview v-if="showError" :error-data="errorInfo" @close="handleClose" @confirm="handleConfirm" />
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { emitter } from '@/utils/bus'
import ErrorPreview from '@/components/errorPreview/index.vue'
const showError = ref(false)
const errorInfo = ref(null)
let cb = null
const showErrorDialog = (data) => {
// 这玩意同时只允许存在一个
if(showError.value) return
errorInfo.value = data
showError.value = true
cb = data?.fn || null
}
const handleClose = () => {
showError.value = false
errorInfo.value = null
cb = null
}
const handleConfirm = (code) => {
cb && cb(code)
handleClose()
}
emitter.on('show-error', showErrorDialog)
onUnmounted(() => {
emitter.off('show-error', showErrorDialog)
})
</script>

View File

@@ -11,12 +11,22 @@
<span>
<a
class="font-bold text-active"
href="https://echol.cn"
>Echo</a
href="https://github.com/flipped-aurora/gin-vue-admin"
>Gin-Vue-Admin</a
>
</span>
</div>
<slot />
<div class="text-center">
<span class="mr-1">Copyright</span>
<span>
<a
class="font-bold text-active"
href="https://github.com/flipped-aurora"
>flipped-aurora团队</a
>
</span>
</div>
</div>
</template>
@@ -25,4 +35,10 @@
name: 'BottomInfo'
})
console.log(
`%c powered by %c flipped-aurorae %c`,
'background:#0081ff; padding: 1px; border-radius: 3px 0 0 3px; color: #fff',
'background:#354855; padding: 1px 5px; border-radius: 0 3px 3px 0; color: #fff; font-weight: bold;',
'background:transparent'
)
</script>

View File

@@ -55,7 +55,6 @@
const deepMenus = (menus) => {
const arr = []
menus?.forEach((menu) => {
if (!menu?.children) return
if (menu.children && menu.children.length > 0) {
arr.push(...deepMenus(menu.children))
} else {

View File

@@ -0,0 +1,126 @@
<template>
<div
class="fixed inset-0 bg-black/40 flex items-center justify-center z-[999]"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-dialog w-full max-w-md mx-4 transform transition-all duration-300 ease-in-out">
<!-- 弹窗头部 -->
<div class="p-5 border-b border-gray-100 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-800">{{ displayData.title }}</h3>
<div class="text-gray-400 hover:text-gray-600 transition-colors cursor-pointer" @click="closeModal">
<close class="h-6 w-6" />
</div>
</div>
<!-- 弹窗内容 -->
<div class="p-6 pt-0">
<!-- 错误类型 -->
<div class="mb-4">
<div class="text-xs font-medium text-gray-500 uppercase mb-2">错误类型</div>
<div class="flex items-center gap-2">
<lock v-if="displayData.icon === 'lock'" class="text-red-500 w-5 h-5" />
<warn v-if="displayData.icon === 'warn'" class="text-red-500 w-5 h-5" />
<server v-if="displayData.icon === 'server'" class="text-red-500 w-5 h-5" />
<span class="font-medium text-gray-800">{{ displayData.type }}</span>
</div>
</div>
<!-- 具体错误 -->
<div class="mb-6">
<div class="text-xs font-medium text-gray-500 uppercase mb-2">具体错误</div>
<div class="bg-gray-100 rounded-lg p-3 text-sm text-gray-700 leading-relaxed">
{{ displayData.message }}
</div>
</div>
<!-- 提示信息 -->
<div v-if="displayData.tips">
<div class="text-xs font-medium text-gray-500 uppercase mb-2">提示</div>
<div class="flex items-center gap-2">
<idea class="text-blue-500 w-5 h-5" />
<p class="text-sm text-gray-600">{{ displayData.tips }}</p>
</div>
</div>
</div>
<!-- 弹窗底部 -->
<div class="py-2 px-4 border-t border-gray-100 flex justify-end">
<div class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm shadow-sm cursor-pointer" @click="handleConfirm">
确定
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, computed } from 'vue';
const props = defineProps({
errorData: {
type: Object,
required: true
}
});
const emits = defineEmits(['close', 'confirm']);
const presetErrors = {
500: {
title: '检测到接口错误',
type: '服务器发生内部错误',
icon: 'server',
color: 'text-red-500',
tips: '此类错误内容常见于后台panic请先查看后台日志如果影响您正常使用可强制登出清理缓存'
},
404: {
title: '资源未找到',
type: 'Not Found',
icon: 'warn',
color: 'text-orange-500',
tips: '此类错误多为接口未注册或未重启或者请求路径方法与api路径方法不符--如果为自动化代码请检查是否存在空格'
},
401: {
title: '身份认证失败',
type: '身份令牌无效',
icon: 'lock',
color: 'text-purple-500',
tips: '您的身份认证已过期或无效,请重新登录。'
},
'network': {
title: '网络错误',
type: 'Network Error',
icon: 'fa-wifi-slash',
color: 'text-gray-500',
tips: '无法连接到服务器,请检查您的网络连接。'
}
};
const displayData = computed(() => {
const preset = presetErrors[props.errorData.code];
if (preset) {
return {
...preset,
message: props.errorData.message || '没有提供额外信息。'
};
}
return {
title: '未知错误',
type: '检测到请求错误',
icon: 'fa-question-circle',
color: 'text-gray-400',
message: props.errorData.message || '发生了一个未知错误。',
tips: '请检查控制台获取更多信息。'
};
});
const closeModal = () => {
emits('close')
};
const handleConfirm = () => {
emits('confirm', props.errorData.code);
closeModal();
};
</script>

View File

@@ -9,6 +9,10 @@
import { exportExcel } from '@/api/exportTemplate'
const props = defineProps({
filterDeleted: {
type: Boolean,
default: true
},
templateId: {
type: String,
required: true
@@ -43,6 +47,11 @@ import { exportExcel } from '@/api/exportTemplate'
baseUrl = ""
}
const paramsCopy = JSON.parse(JSON.stringify(props.condition))
if (props.filterDeleted) {
paramsCopy.filterDeleted = 'true'
}
if (props.limit) {
paramsCopy.limit = props.limit
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="border border-solid border-gray-100 h-full">
<div class="border border-solid border-gray-100 h-full z-10">
<Toolbar
:editor="editorRef"
:default-config="toolbarConfig"
@@ -8,7 +8,7 @@
<Editor
v-model="valueHtml"
class="overflow-y-hidden mt-0.5"
style="min-height: 18rem"
style="height: 18rem"
:default-config="editorConfig"
mode="default"
@onCreated="handleCreated"
@@ -27,7 +27,7 @@
import { ElMessage } from 'element-plus'
import { getUrl } from '@/utils/image'
import botLogo from '@/assets/bot_logo.png'
import { useUserStore } from '@/pinia/modules/user'
const emits = defineEmits(['change', 'update:modelValue'])
@@ -36,14 +36,11 @@
emits('update:modelValue', valueHtml.value)
}
const userStore = useUserStore()
const props = defineProps({
modelValue: {
type: String,
default: ''
},
useWatermark: {
type: Boolean,
default: false
}
})
@@ -53,94 +50,14 @@
const toolbarConfig = {}
const editorConfig = {
placeholder: '请输入内容...',
MENU_CONF: {
uploadImage: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 10, // 限制图片大小为10MB
maxNumberOfFiles: 1,
async customUpload(file, insertFn) {
// 未开启水印则直接上传原图
if (!props.useWatermark) {
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', { method: 'POST', body: formData })
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
return
}
try {
const watermarkedBlob = await addBottomWatermark(file, {
stripRatio: 0.18, // 水印条高度占原图高度比例
background: 'rgba(255,255,255,0.96)',
text: '老陈机器人',
textColor: '#333',
fontFamily: 'PingFang SC, Microsoft YaHei, Arial',
logo: botLogo
})
const newFile = new File([watermarkedBlob], file.name, { type: watermarkedBlob.type || file.type })
const formData = new FormData()
formData.append('file', newFile)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', {
method: 'POST',
body: formData
})
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch {
ElMessage.error('处理水印失败')
// 降级:直接走原图上传
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', { method: 'POST', body: formData })
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
}
},
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
return
}
ElMessage.error(res.msg)
}
},
uploadVideo: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 100, // 限制视频大小为100MB
maxNumberOfFiles: 1,
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
return
}
ElMessage.error(res.msg)
}
}
}
MENU_CONF: {}
}
editorConfig.MENU_CONF['uploadImage','uploadVideo'] = {
editorConfig.MENU_CONF['uploadImage'] = {
fieldName: 'file',
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
headers: {
'x-token': userStore.token,
},
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
@@ -169,94 +86,8 @@
valueHtml.value = props.modelValue
}
)
async function addBottomWatermark(file, options) {
const { stripRatio = 0.18, background = 'rgba(255,255,255,0.96)', text = '', textColor = '#333', fontFamily = 'Arial', logo } = options || {}
const img = await fileToImage(file)
const width = img.naturalWidth || img.width
const height = img.naturalHeight || img.height
const stripHeight = Math.max(60, Math.floor(height * stripRatio))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height + stripHeight
const ctx = canvas.getContext('2d')
// 原图
ctx.drawImage(img, 0, 0, width, height)
// 底部水印条背景
ctx.fillStyle = background
ctx.fillRect(0, height, width, stripHeight)
// 左侧 Logo可选
let logoSize = Math.floor(stripHeight * 0.6)
let logoPadding = Math.floor(stripHeight * 0.2)
if (logo) {
try {
const logoImg = await srcToImage(logo)
const ratio = logoImg.width / logoImg.height
const drawW = logoSize
const drawH = Math.floor(drawW / ratio)
const y = height + Math.floor((stripHeight - drawH) / 2)
ctx.drawImage(logoImg, logoPadding, y, drawW, drawH)
} catch { void 0 }
}
// 右侧文字
ctx.fillStyle = textColor
const fontSize = Math.floor(stripHeight * 0.38)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
const textPadding = Math.floor(stripHeight * 0.25)
ctx.fillText(text, width - textPadding, height + Math.floor(stripHeight / 2))
const blob = await canvasToBlob(canvas, file.type || 'image/png')
return blob
}
function fileToImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = reader.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function srcToImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
function canvasToBlob(canvas, mime) {
return new Promise((resolve) => {
if (canvas.toBlob) {
canvas.toBlob((blob) => resolve(blob), mime, 0.92)
} else {
// 兼容处理
const dataURL = canvas.toDataURL(mime)
const arr = dataURL.split(',')
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) u8arr[n] = bstr.charCodeAt(n)
resolve(new Blob([u8arr], { type: mime }))
}
})
}
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
</style>

View File

@@ -47,7 +47,7 @@
const model = defineModel({ type: Array })
const fileList = ref(model.value)
const fileList = ref(model.value || [])
const emits = defineEmits(['on-success', 'on-error'])

View File

@@ -2,9 +2,27 @@
<div>
<selectComponent :rounded="rounded" v-if="!props.multiple" :model="model" @chooseItem="openChooseImg" @deleteItem="openChooseImg" />
<div v-else class="w-full gap-4 flex flex-wrap">
<selectComponent :rounded="rounded" v-for="(item, index) in model" :key="index" :model="item" @chooseItem="openChooseImg"
@deleteItem="deleteImg(index)"
/>
<draggable
v-model="model"
class="flex flex-wrap gap-4"
item-key="url"
ghost-class="ghost-item"
handle=".drag-handle"
animation="300"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{element, index}">
<div class="relative group">
<div class="drag-handle absolute left-2 top-2 w-8 h-8 flex items-center justify-center cursor-move z-10 opacity-0 group-hover:opacity-100 rounded-full">
<el-icon :size="18"><Menu /></el-icon>
</div>
<selectComponent :rounded="rounded" :model="element" @chooseItem="openChooseImg"
@deleteItem="deleteImg(index)"
/>
</div>
</template>
</draggable>
<selectComponent :rounded="rounded" v-if="model?.length < props.maxUpdateCount || props.maxUpdateCount === 0"
@chooseItem="openChooseImg" @deleteItem="openChooseImg"
/>
@@ -140,6 +158,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeftBold,
CloseBold,
Menu,
MoreFilled,
Picture as IconPicture,
Plus,
@@ -149,6 +168,7 @@ import selectComponent from '@/components/selectImage/selectComponent.vue'
import { addCategory, deleteCategory, getCategoryList } from '@/api/attachmentCategory'
import CropperImage from "@/components/upload/cropper.vue";
import QRCodeUpload from "@/components/upload/QR-code.vue";
import draggable from 'vuedraggable'
const imageUrl = ref('')
const imageCommon = ref('')
@@ -424,6 +444,20 @@ const useSelectedImages = () => {
selectedImages.value = []
}
const onDragStart = () => {
// 拖拽开始时的处理
document.body.style.cursor = 'grabbing'
}
const onDragEnd = () => {
// 拖拽结束时的处理
document.body.style.cursor = 'default'
// 确保model是数组类型
if (!Array.isArray(model.value)) {
model.value = []
}
}
</script>
<style scoped>
.selected {
@@ -450,4 +484,20 @@ const useSelectedImages = () => {
border-left-color: transparent;
transform: rotate(45deg);
}
.ghost-item {
opacity: 0.5;
background: #c8ebfb;
border: 1px dashed #409eff;
}
.drag-handle {
background-color: rgba(64, 158, 255, 0.1);
border-radius: 4px;
transition: opacity 0.3s;
}
.drag-handle:hover {
background-color: rgba(64, 158, 255, 0.2);
}
</style>