🎨 更新用户版本
This commit is contained in:
39
src/components/application/index.vue
Normal file
39
src/components/application/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
126
src/components/errorPreview/index.vue
Normal file
126
src/components/errorPreview/index.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user