备货单 添加预览 跟进列表分页 审批记录显示问题处理
This commit is contained in:
@@ -31,12 +31,10 @@
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<el-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="downloadFile(file)"
|
||||
icon="el-icon-download"
|
||||
>
|
||||
<el-button type="text" size="small" @click="filePreview(file)" icon="el-icon-download">
|
||||
预览
|
||||
</el-button>
|
||||
<el-button type="text" size="small" @click="downloadFile(file)" icon="el-icon-download">
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
@@ -51,224 +49,236 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<files-previews v-model="fileshow" :file="filepviews" @close="onClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="MultiFileUpload">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
const actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=stock_orders`
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
interface FileItem {
|
||||
name: string
|
||||
url: string
|
||||
file_name: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: FileItem[]
|
||||
uploadText?: string
|
||||
hintText?: string
|
||||
showHint?: boolean
|
||||
iconSrc?: string
|
||||
accept?: string
|
||||
maxSize?: number // MB
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: FileItem[]): void
|
||||
(e: 'change', value: FileItem[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
uploadText: '点击上传文件',
|
||||
hintText: '支持多个文件上传',
|
||||
showHint: true,
|
||||
iconSrc: '/src/assets/img/crm/contract.png',
|
||||
accept: '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt',
|
||||
maxSize: 10
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fileList = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUploadHeaders } from '@/composables/useUploadHeaders'
|
||||
import FilesPreviews from '@/components/custom/files_previews.vue'
|
||||
const actionImageUplode = `${import.meta.env.VITE_API_URL}/ticket/upload?scene=stock_orders`
|
||||
const { uploadHeaders } = useUploadHeaders()
|
||||
const fileshow = ref(false)
|
||||
const filepviews = ref({} as FileItem)
|
||||
function onClose() {
|
||||
console.log('文件预览已关闭')
|
||||
}
|
||||
})
|
||||
|
||||
const isImageFile = (file: FileItem) => {
|
||||
const stripQuery = (s?: string) => (s ? s.split('?')[0] : '')
|
||||
const byExt = (s?: string) => /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(stripQuery(s || ''))
|
||||
// Prefer extension checks on url, then file_name, then name
|
||||
if (byExt(file.url)) return true
|
||||
if (byExt(file.file_name)) return true
|
||||
if (byExt(file.name)) return true
|
||||
// Fallback: some backends may return mime in file_name like 'image/jpeg'
|
||||
return (file.file_name || '').toLowerCase().startsWith('image/')
|
||||
}
|
||||
|
||||
const beforeUpload = (file: File) => {
|
||||
const isValidType = new RegExp(props.accept.replace(/\./g, '\\.').replace(/,/g, '|'), 'i').test(file.name)
|
||||
if (!isValidType) {
|
||||
ElMessage.error(`只能上传${props.accept}格式的文件!`)
|
||||
return false
|
||||
interface FileItem {
|
||||
name: string
|
||||
url: string
|
||||
file_name: string
|
||||
}
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
|
||||
if (!isLtMaxSize) {
|
||||
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUpload = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
const fileInfo: FileItem = {
|
||||
name: response.data.original_name || '文件',
|
||||
url: response.data.file_url,
|
||||
file_name: response.data.file_name
|
||||
interface Props {
|
||||
modelValue: FileItem[]
|
||||
uploadText?: string
|
||||
hintText?: string
|
||||
showHint?: boolean
|
||||
iconSrc?: string
|
||||
accept?: string
|
||||
maxSize?: number // MB
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: FileItem[]): void
|
||||
(e: 'change', value: FileItem[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
uploadText: '点击上传文件',
|
||||
hintText: '支持多个文件上传',
|
||||
showHint: true,
|
||||
iconSrc: '/src/assets/img/crm/contract.png',
|
||||
accept: '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt',
|
||||
maxSize: 10
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fileList = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
const newFileList = [...fileList.value, fileInfo]
|
||||
fileList.value = newFileList
|
||||
ElMessage.success('文件上传成功!')
|
||||
} else {
|
||||
ElMessage.error(response.message || '文件上传失败!')
|
||||
})
|
||||
const filePreview = (file: FileItem) => {
|
||||
filepviews.value = file
|
||||
fileshow.value = true
|
||||
}
|
||||
const isImageFile = (file: FileItem) => {
|
||||
const stripQuery = (s?: string) => (s ? s.split('?')[0] : '')
|
||||
const byExt = (s?: string) => /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(stripQuery(s || ''))
|
||||
// Prefer extension checks on url, then file_name, then name
|
||||
if (byExt(file.url)) return true
|
||||
if (byExt(file.file_name)) return true
|
||||
if (byExt(file.name)) return true
|
||||
// Fallback: some backends may return mime in file_name like 'image/jpeg'
|
||||
return (file.file_name || '').toLowerCase().startsWith('image/')
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFileList = [...fileList.value]
|
||||
newFileList.splice(index, 1)
|
||||
fileList.value = newFileList
|
||||
ElMessage.success('文件已删除')
|
||||
}
|
||||
const beforeUpload = (file: File) => {
|
||||
const isValidType = new RegExp(props.accept.replace(/\./g, '\\.').replace(/,/g, '|'), 'i').test(
|
||||
file.name
|
||||
)
|
||||
if (!isValidType) {
|
||||
ElMessage.error(`只能上传${props.accept}格式的文件!`)
|
||||
return false
|
||||
}
|
||||
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
|
||||
if (!isLtMaxSize) {
|
||||
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const downloadFile = (file: FileItem) => {
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.download = file.name
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
const handleUpload = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
const fileInfo: FileItem = {
|
||||
name: response.data.original_name || '文件',
|
||||
url: response.data.file_url,
|
||||
file_name: response.data.file_name
|
||||
}
|
||||
const newFileList = [...fileList.value, fileInfo]
|
||||
fileList.value = newFileList
|
||||
ElMessage.success('文件上传成功!')
|
||||
} else {
|
||||
ElMessage.error(response.message || '文件上传失败!')
|
||||
}
|
||||
}
|
||||
|
||||
// 导出组件
|
||||
defineExpose({})
|
||||
const removeFile = (index: number) => {
|
||||
const newFileList = [...fileList.value]
|
||||
newFileList.splice(index, 1)
|
||||
fileList.value = newFileList
|
||||
ElMessage.success('文件已删除')
|
||||
}
|
||||
|
||||
const downloadFile = (file: FileItem) => {
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a')
|
||||
link.href = file.url
|
||||
link.download = file.name
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 导出组件
|
||||
defineExpose({})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.multi-file-upload {
|
||||
.upload-box {
|
||||
width: 100%;
|
||||
}
|
||||
.multi-file-upload {
|
||||
.upload-box {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
// border: 2px dashed #d9d9d9;
|
||||
// border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
// background: #fafafa;
|
||||
.upload-trigger {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
// border: 2px dashed #d9d9d9;
|
||||
// border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
// background: #fafafa;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
background: #f0f9ff;
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// 文件列表样式
|
||||
.files-list {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f9ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-preview-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// 文件列表样式
|
||||
.files-list {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #f0f9ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-preview-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
margin-right: 12px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<el-button type="text" size="small" @click="previewFile(file)">预览</el-button>
|
||||
<el-button type="primary" size="small" @click="downloadFile(file)">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,13 +38,14 @@
|
||||
<span>{{ emptyText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<files-previews v-model="fileshow" :file="filepviews" @close="onClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import FilesPreviews from '@/components/custom/files_previews.vue'
|
||||
interface FileItem {
|
||||
name: string
|
||||
url: string
|
||||
@@ -52,6 +54,7 @@
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FilePreview',
|
||||
components: { FilesPreviews },
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
@@ -67,18 +70,22 @@
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const fileshow = ref(false)
|
||||
const filepviews = ref({} as FileItem)
|
||||
function onClose() {
|
||||
fileshow.value = false
|
||||
console.log('文件预览已关闭')
|
||||
}
|
||||
// 判断是否为图片文件
|
||||
const isImageFile = (file: FileItem) => {
|
||||
// 检查文件名
|
||||
if (file.name && /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i.test(file.name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查file_name字段
|
||||
if (file.file_name && file.file_name.startsWith('image/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查URL中的文件扩展名(去除查询参数)
|
||||
if (file.url) {
|
||||
const urlWithoutParams = file.url.split('?')[0] // 移除查询参数
|
||||
@@ -86,7 +93,6 @@
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -185,8 +191,16 @@
|
||||
ElMessage.error(`下载失败: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
const previewFile = (file: FileItem) => {
|
||||
filepviews.value = file
|
||||
fileshow.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
fileshow,
|
||||
filepviews,
|
||||
previewFile,
|
||||
onClose,
|
||||
isImageFile,
|
||||
previewImage,
|
||||
downloadFile
|
||||
@@ -289,7 +303,7 @@
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visibleLocal"
|
||||
:title="currentFile?.name || '文件预览'"
|
||||
:close-on-click-modal="false"
|
||||
:destroy-on-close="false"
|
||||
width="90%"
|
||||
:fullscreen="isFullscreen"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="file-preview-toolbar">
|
||||
<div class="left">
|
||||
<!-- <el-button-group>
|
||||
<el-button size="small" @click="prevFile" :disabled="!hasPrev">上一页</el-button>
|
||||
<el-button size="small" @click="nextFile" :disabled="!hasNext">下一页</el-button>
|
||||
</el-button-group>
|
||||
|
||||
<el-divider direction="vertical" />
|
||||
|
||||
<el-button size="small" @click="zoomOut">-</el-button>
|
||||
<el-button size="small" @click="resetZoom">重置</el-button>
|
||||
<el-button size="small" @click="zoomIn">+</el-button>
|
||||
|
||||
<el-divider direction="vertical" /> -->
|
||||
|
||||
<!-- <el-button size="small" @click="rotateLeft">⟲</el-button> -->
|
||||
<!-- <el-button size="small" @click="rotateRight">⟳</el-button> -->
|
||||
|
||||
<!-- <el-divider direction="vertical" />
|
||||
|
||||
<el-button size="small" @click="toggleFullscreen">{{
|
||||
isFullscreen ? '退出全屏' : '全屏'
|
||||
}}</el-button> -->
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<!-- <el-button size="small" type="primary" :href="currentFile?.url" target="_blank"
|
||||
>在新标签打开</el-button
|
||||
> -->
|
||||
<el-button size="small" @click="downloadCurrent">下载</el-button>
|
||||
<el-button size="small" type="danger" @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-preview-body">
|
||||
<template v-if="!currentFile">
|
||||
<div class="no-file">无要预览的文件</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- IMAGE -->
|
||||
<div v-if="fileType === 'image'" class="viewer-wrap">
|
||||
<div class="viewer" :style="viewerStyle">
|
||||
<img :src="currentFile.url" @error="onError" @load="onLoadImage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF -->
|
||||
<div v-else-if="fileType === 'pdf'" class="viewer-wrap">
|
||||
<div class="viewer" :style="viewerStyle">
|
||||
<!-- 使用 object / iframe 作为预览,支持大多数现代浏览器 -->
|
||||
<object
|
||||
:data="appendPdfParams(currentFile.url)"
|
||||
type="application/pdf"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<iframe :src="appendPdfParams(currentFile.url)" width="100%" height="100%"></iframe>
|
||||
<div class="fallback"
|
||||
>无法在浏览器内预览 PDF。<a :href="currentFile.url" target="_blank"
|
||||
>点击打开</a
|
||||
></div
|
||||
>
|
||||
</object>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OFFICE (Word, Excel, PPT) 使用微软 Office 在线查看器,仅当文件对外可访问时有效 -->
|
||||
<div v-else-if="fileType === 'office'" class="viewer-wrap">
|
||||
<div class="viewer" :style="viewerStyle">
|
||||
<template v-if="isHttpUrl(currentFile.url)">
|
||||
<iframe
|
||||
:src="officeViewerUrl(currentFile.url)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameborder="0"
|
||||
></iframe>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="fallback"
|
||||
>Office 文件必须是可通过公网访问的 URL 才能使用在线预览。<a
|
||||
:href="currentFile.url"
|
||||
target="_blank"
|
||||
>点击打开或下载</a
|
||||
></div
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文本 / 代码 -->
|
||||
<div v-else-if="fileType === 'text'" class="viewer-wrap">
|
||||
<div class="viewer text-viewer" :style="viewerStyle">
|
||||
<pre v-if="textContent !== null">{{ textContent }}</pre>
|
||||
<div v-else class="loading">正在加载文本内容...</div>
|
||||
<div v-if="textError" class="error">加载文本失败:{{ textError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未知 -->
|
||||
<div v-else class="viewer-wrap unknown-viewer">
|
||||
<div class="viewer" :style="viewerStyle">
|
||||
<div class="unknown">
|
||||
<p>无法在线预览此类型文件。</p>
|
||||
<p><a :href="currentFile.url" target="_blank">点击打开或下载文件</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="footer-info">{{ currentFile?.name || '' }}</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="FilesPreviews">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// --- Props ---
|
||||
interface FileItem {
|
||||
name?: string
|
||||
url: string
|
||||
mime?: string // 可选,优先使用
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
file?: FileItem | null // 单文件
|
||||
files?: FileItem[] | null // 多文件
|
||||
startIndex?: number
|
||||
enableFullscreen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
// --- Local state ---
|
||||
const visibleLocal = ref<boolean>(!!props.modelValue)
|
||||
const currentIndex = ref<number>(props.startIndex ?? 0)
|
||||
const isFullscreen = ref<boolean>(!!props.enableFullscreen)
|
||||
const zoom = ref<number>(1)
|
||||
const rotate = ref<number>(0) // deg
|
||||
const textContent = ref<string | null>(null)
|
||||
const textError = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => (visibleLocal.value = v)
|
||||
)
|
||||
watch(visibleLocal, (v) => emit('update:modelValue', v))
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
() => {
|
||||
currentIndex.value = 0
|
||||
resetViewState()
|
||||
loadTextIfNeeded()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.files,
|
||||
() => {
|
||||
currentIndex.value = props.startIndex ?? 0
|
||||
resetViewState()
|
||||
loadTextIfNeeded()
|
||||
}
|
||||
)
|
||||
|
||||
watch(currentIndex, () => {
|
||||
resetViewState(false)
|
||||
loadTextIfNeeded()
|
||||
})
|
||||
|
||||
function resetViewState(clearText = true) {
|
||||
zoom.value = 1
|
||||
rotate.value = 0
|
||||
if (clearText) {
|
||||
textContent.value = null
|
||||
textError.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const currentFile = computed((): FileItem | null => {
|
||||
if (props.files && props.files.length > 0) {
|
||||
const idx = Math.min(Math.max(0, currentIndex.value), props.files.length - 1)
|
||||
return props.files[idx]
|
||||
}
|
||||
return props.file ?? null
|
||||
})
|
||||
|
||||
const hasNext = computed(
|
||||
() => !!(props.files && currentIndex.value < (props.files?.length ?? 0) - 1)
|
||||
)
|
||||
const hasPrev = computed(() => !!(props.files && currentIndex.value > 0))
|
||||
|
||||
function prevFile() {
|
||||
if (hasPrev.value) currentIndex.value -= 1
|
||||
}
|
||||
function nextFile() {
|
||||
if (hasNext.value) currentIndex.value += 1
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visibleLocal.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// --- Viewer helpers ---
|
||||
function getExtension(name = '') {
|
||||
const m = name.split('.')
|
||||
return (m.length > 1 ? m[m.length - 1].toLowerCase() : '').trim()
|
||||
}
|
||||
|
||||
function mimeFromName(name = '') {
|
||||
const ext = getExtension(name)
|
||||
const map: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
bmp: 'image/bmp',
|
||||
webp: 'image/webp',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
}
|
||||
return map[ext] ?? ''
|
||||
}
|
||||
|
||||
const fileType = computed(() => {
|
||||
const f = currentFile.value
|
||||
if (!f) return 'unknown'
|
||||
const mime = (f.mime || mimeFromName(f.name) || '').toLowerCase()
|
||||
const ext = getExtension(f.name)
|
||||
|
||||
if (mime.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'].includes(ext))
|
||||
return 'image'
|
||||
if (mime === 'application/pdf' || ext === 'pdf') return 'pdf'
|
||||
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) return 'office'
|
||||
if (mime.startsWith('text/') || ['txt', 'md', 'csv', 'log', 'json', 'xml'].includes(ext))
|
||||
return 'text'
|
||||
return 'unknown'
|
||||
})
|
||||
|
||||
function isHttpUrl(u?: string) {
|
||||
return !!u && /^https?:\/\//i.test(u)
|
||||
}
|
||||
|
||||
function officeViewerUrl(url: string) {
|
||||
// 使用微软 Office 在线查看器
|
||||
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
function appendPdfParams(url: string) {
|
||||
// 某些浏览器可以在 pdf 后加参数调整行为,例如 #toolbar=0
|
||||
return url
|
||||
}
|
||||
|
||||
// --- transform style for viewer ---
|
||||
const viewerStyle = computed(() => {
|
||||
return {
|
||||
transform: `scale(${zoom.value}) rotate(${rotate.value}deg)`,
|
||||
transformOrigin: 'center top'
|
||||
} as Record<string, string>
|
||||
})
|
||||
|
||||
function zoomIn() {
|
||||
zoom.value = Math.min(zoom.value + 0.25, 4)
|
||||
}
|
||||
function zoomOut() {
|
||||
zoom.value = Math.max(zoom.value - 0.25, 0.25)
|
||||
}
|
||||
function resetZoom() {
|
||||
zoom.value = 1
|
||||
rotate.value = 0
|
||||
}
|
||||
function rotateLeft() {
|
||||
rotate.value = (rotate.value - 90) % 360
|
||||
}
|
||||
function rotateRight() {
|
||||
rotate.value = (rotate.value + 90) % 360
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
}
|
||||
|
||||
// --- text loading ---
|
||||
async function loadTextIfNeeded() {
|
||||
if (!currentFile.value) return
|
||||
if (fileType.value !== 'text') return
|
||||
|
||||
textContent.value = null
|
||||
textError.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch(currentFile.value.url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const txt = await resp.text()
|
||||
textContent.value = txt
|
||||
} catch (err: any) {
|
||||
textError.value = String(err?.message ?? err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- image handlers ---
|
||||
function onError() {
|
||||
ElMessage.warning('图片加载失败')
|
||||
}
|
||||
function onLoadImage() {
|
||||
// 可以在这里获取自然尺寸进行布局优化
|
||||
}
|
||||
|
||||
function downloadCurrent() {
|
||||
if (!currentFile.value) return
|
||||
// 打开下载链接(浏览器通常会下载)
|
||||
const a = document.createElement('a')
|
||||
a.href = currentFile.value.url
|
||||
a.download = currentFile.value.name || ''
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
// expose some controls externally if needed
|
||||
onMounted(() => {
|
||||
if (props.modelValue) visibleLocal.value = true
|
||||
if (fileType.value === 'text') loadTextIfNeeded()
|
||||
})
|
||||
// 导出组件
|
||||
defineExpose({})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file-preview-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.file-preview-body {
|
||||
height: calc(80vh - 80px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.viewer-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
}
|
||||
.viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.viewer img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.text-viewer pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
.unknown-viewer .unknown {
|
||||
text-align: center;
|
||||
}
|
||||
.no-file {
|
||||
color: #999;
|
||||
}
|
||||
.footer-info {
|
||||
color: #666;
|
||||
}
|
||||
.loading {
|
||||
color: #999;
|
||||
}
|
||||
.error {
|
||||
color: #f56c6c;
|
||||
}
|
||||
</style>
|
||||
@@ -69,7 +69,17 @@ export function useMenuBadge() {
|
||||
|
||||
// 处理WebSocket徽标更新消息
|
||||
const handleWebSocketBadgeUpdate = (data: any) => {
|
||||
// console.log('收到WebSocket徽标更新消息:', data)
|
||||
// 防止重复处理
|
||||
if (data._badgeHandled) {
|
||||
return
|
||||
}
|
||||
data._badgeHandled = true
|
||||
|
||||
// 首先检查是否是数组格式的徽标数据
|
||||
if (Array.isArray(data) || (data.data && Array.isArray(data.data))) {
|
||||
handleMenuBadgeDataArray(data)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 根据消息类型处理不同的徽标更新
|
||||
@@ -98,11 +108,37 @@ export function useMenuBadge() {
|
||||
|
||||
// 处理菜单徽标数据数组格式
|
||||
const handleMenuBadgeDataArray = (data: any) => {
|
||||
// console.log('收到菜单徽标数据数组:', data)
|
||||
// 防止重复处理
|
||||
if (data._arrayHandled) {
|
||||
return
|
||||
}
|
||||
data._arrayHandled = true
|
||||
|
||||
if (Array.isArray(data.data)) {
|
||||
const updates = data.data
|
||||
.filter((item: any) => item.url && typeof item.badge === 'number')
|
||||
// 支持多种数据格式:
|
||||
// 1. { event: '...', data: [{ url: '...', badge: 3 }] }
|
||||
// 2. [{ url: '...', badge: 3 }] (直接是数组)
|
||||
let badgeArray: any[] = []
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
// 如果 data 本身就是数组
|
||||
badgeArray = data
|
||||
} else if (Array.isArray(data.data)) {
|
||||
// 如果 data.data 是数组
|
||||
badgeArray = data.data
|
||||
} else {
|
||||
console.warn('未找到数组格式的徽标数据:', data)
|
||||
return
|
||||
}
|
||||
|
||||
if (badgeArray.length > 0) {
|
||||
const updates = badgeArray
|
||||
.filter((item: any) => {
|
||||
const isValid = item.url && typeof item.badge === 'number'
|
||||
if (!isValid) {
|
||||
console.warn('跳过无效的徽标数据项:', item)
|
||||
}
|
||||
return isValid
|
||||
})
|
||||
.map((item: any) => ({
|
||||
path: item.url,
|
||||
count: item.badge,
|
||||
@@ -110,9 +146,12 @@ export function useMenuBadge() {
|
||||
}))
|
||||
|
||||
if (updates.length > 0) {
|
||||
// console.log('批量更新菜单徽标:', updates)
|
||||
batchUpdateMenuBadges(updates)
|
||||
} else {
|
||||
console.warn('没有有效的徽标更新项')
|
||||
}
|
||||
} else {
|
||||
console.warn('徽标数据数组为空')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +176,11 @@ export function useMenuBadge() {
|
||||
|
||||
// 处理审批相关的徽标更新
|
||||
const handleApprovalBadgeUpdate = (data: any) => {
|
||||
// console.log('收到审批徽标更新消息:', data)
|
||||
// 首先检查是否是数组格式的徽标数据
|
||||
if (Array.isArray(data) || Array.isArray(data.data)) {
|
||||
handleMenuBadgeDataArray(data)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.event === 'approval.new') {
|
||||
// 新审批,更新审批菜单徽标
|
||||
@@ -167,7 +210,22 @@ export function useMenuBadge() {
|
||||
|
||||
// 统一的WebSocket消息处理
|
||||
const handleWebSocketMessage = (data: any) => {
|
||||
if (!data || !data.event) return
|
||||
if (!data) return
|
||||
|
||||
// 如果已经被特定事件处理过(如 menu-badge-update),则跳过
|
||||
// 注意:_badgeProcessed 标记是在 wsClient.ts 中设置的
|
||||
if (data._badgeProcessed) {
|
||||
return
|
||||
}
|
||||
|
||||
// 首先检查是否是数组格式的徽标数据(无论是否有 event 字段)
|
||||
if (Array.isArray(data) || (data.data && Array.isArray(data.data))) {
|
||||
handleMenuBadgeDataArray(data)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果没有 event 字段,无法继续处理
|
||||
if (!data.event) return
|
||||
|
||||
// 处理菜单徽标数据数组格式(如 approval.todo.counts)
|
||||
if (data.event.includes('.todo.counts') || data.event.includes('.badge.counts')) {
|
||||
@@ -186,11 +244,9 @@ export function useMenuBadge() {
|
||||
// 初始化徽标系统
|
||||
const initBadgeSystem = () => {
|
||||
if (globalInitialized) {
|
||||
// console.log('菜单徽标系统已经全局初始化,跳过重复初始化')
|
||||
return
|
||||
}
|
||||
|
||||
// console.log('初始化菜单徽标系统')
|
||||
globalInitialized = true
|
||||
|
||||
// 标记页面已加载完成
|
||||
|
||||
@@ -38,11 +38,11 @@ export interface BaseForm {
|
||||
cif_total_currency_snapshot: string
|
||||
is_final: string
|
||||
object_id: string | number
|
||||
contract: string[]
|
||||
contract: any[]
|
||||
contract_url?: string[]
|
||||
pi_doc: string[]
|
||||
pi_doc: any[]
|
||||
pi_doc_url?: string[]
|
||||
transaction_slip: string[]
|
||||
transaction_slip: any[]
|
||||
transaction_slip_url?: string[]
|
||||
}
|
||||
|
||||
@@ -686,7 +686,11 @@ export function useStockingForm(isFinal = false, stockingId?: string | number) {
|
||||
}
|
||||
contractFiles.value.push(fileInfo)
|
||||
// 更新合同字段为文件名数组
|
||||
baseForm.contract = contractFiles.value.map((file) => file.file_name)
|
||||
baseForm.contract = contractFiles.value.map((f) => ({
|
||||
url: f.url,
|
||||
original_name: f.name
|
||||
})) as any
|
||||
// baseForm.contract = contractFiles.value.map((file) => file.file_name)
|
||||
ElMessage.success('合同上传成功!')
|
||||
} else {
|
||||
ElMessage.error(response.message || '合同上传失败!')
|
||||
@@ -696,7 +700,11 @@ export function useStockingForm(isFinal = false, stockingId?: string | number) {
|
||||
const removeContractFile = (index: number) => {
|
||||
contractFiles.value.splice(index, 1)
|
||||
// 更新合同字段为文件名数组
|
||||
baseForm.contract = contractFiles.value.map((file) => file.file_name)
|
||||
baseForm.contract = contractFiles.value.map((f) => ({
|
||||
url: f.url,
|
||||
original_name: f.name
|
||||
})) as any
|
||||
// baseForm.contract = contractFiles.value.map((file) => file.file_name)
|
||||
ElMessage.success('合同文件已删除')
|
||||
}
|
||||
|
||||
@@ -1037,10 +1045,10 @@ export function useStockingForm(isFinal = false, stockingId?: string | number) {
|
||||
: []
|
||||
baseForm.contract = contractNamesArr
|
||||
baseForm.contract_url = contractUrlsArr
|
||||
contractFiles.value = contractNamesArr.map((fileName: string, index: number) => ({
|
||||
name: fileName,
|
||||
contractFiles.value = contractNamesArr.map((fileName: any, index: number) => ({
|
||||
name: fileName.original_name,
|
||||
url: contractUrlsArr[index] || fileName,
|
||||
file_name: fileName
|
||||
file_name: fileName.url
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1056,10 +1064,10 @@ export function useStockingForm(isFinal = false, stockingId?: string | number) {
|
||||
: []
|
||||
baseForm.pi_doc = piNamesArr
|
||||
baseForm.pi_doc_url = piUrlsArr
|
||||
piFiles.value = piNamesArr.map((fileName: string, index: number) => ({
|
||||
name: fileName,
|
||||
piFiles.value = piNamesArr.map((fileName: any, index: number) => ({
|
||||
name: fileName.original_name,
|
||||
url: piUrlsArr[index] || fileName,
|
||||
file_name: fileName
|
||||
file_name: fileName.url
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1075,10 +1083,10 @@ export function useStockingForm(isFinal = false, stockingId?: string | number) {
|
||||
: []
|
||||
baseForm.transaction_slip = slipNamesArr
|
||||
baseForm.transaction_slip_url = slipUrlsArr
|
||||
waterBillFiles.value = slipNamesArr.map((fileName: string, index: number) => ({
|
||||
name: fileName,
|
||||
waterBillFiles.value = slipNamesArr.map((fileName: any, index: number) => ({
|
||||
name: fileName.original_name,
|
||||
url: slipUrlsArr[index] || fileName,
|
||||
file_name: fileName
|
||||
file_name: fileName.url
|
||||
}))
|
||||
}
|
||||
// 等待本轮变更完成后,再恢复自动计算
|
||||
|
||||
+272
-280
@@ -16,288 +16,280 @@ export interface MenuBadgeData {
|
||||
}
|
||||
|
||||
// 菜单徽标Store
|
||||
export const useMenuBadgeStore = defineStore(
|
||||
'menuBadgeStore',
|
||||
() => {
|
||||
// 徽标数据映射 - 使用普通对象而不是Map,避免持久化问题
|
||||
const badgeData = ref<Record<string, MenuBadgeData>>({})
|
||||
export const useMenuBadgeStore = defineStore('menuBadgeStore', () => {
|
||||
// 徽标数据映射 - 使用普通对象而不是Map,避免持久化问题
|
||||
const badgeData = ref<Record<string, MenuBadgeData>>({})
|
||||
|
||||
// 页面是否已加载完成
|
||||
const isPageReady = ref(false)
|
||||
// 页面是否已加载完成
|
||||
const isPageReady = ref(false)
|
||||
|
||||
// 待处理的徽标更新队列(页面未加载完成时使用)
|
||||
const pendingUpdates = ref<Array<{ path: string; count: number; type: 'dot' | 'text' }>>([])
|
||||
// 待处理的徽标更新队列(页面未加载完成时使用)
|
||||
const pendingUpdates = ref<Array<{ path: string; count: number; type: 'dot' | 'text' }>>([])
|
||||
|
||||
// 获取当前用户ID
|
||||
const getCurrentUserId = (): string => {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
return userStore.info?.userId?.toString() || 'anonymous'
|
||||
} catch {
|
||||
return 'anonymous'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户特定的存储key
|
||||
const getUserStorageKey = (): string => {
|
||||
const userId = getCurrentUserId()
|
||||
return `menu-badge-${userId}`
|
||||
}
|
||||
|
||||
// 从localStorage加载用户特定的徽标数据
|
||||
const loadUserBadgeData = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey()
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
badgeData.value = parsed.badgeData || {}
|
||||
isPageReady.value = parsed.isPageReady || false
|
||||
pendingUpdates.value = parsed.pendingUpdates || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载用户徽标数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户特定的徽标数据到localStorage
|
||||
const saveUserBadgeData = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey()
|
||||
const data = {
|
||||
badgeData: badgeData.value,
|
||||
isPageReady: isPageReady.value,
|
||||
pendingUpdates: pendingUpdates.value
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.warn('保存用户徽标数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理其他用户的徽标数据
|
||||
const cleanupOtherUserData = () => {
|
||||
try {
|
||||
const currentUserId = getCurrentUserId()
|
||||
const keys = Object.keys(localStorage)
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('menu-badge-') && !key.endsWith(`-${currentUserId}`)) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('清理其他用户数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性:获取所有徽标数据
|
||||
const allBadgeData = computed(() => {
|
||||
return { ...badgeData.value }
|
||||
})
|
||||
|
||||
// 获取指定菜单的徽标数据
|
||||
const getBadgeData = (path: string): MenuBadgeData | null => {
|
||||
return badgeData.value[path] || null
|
||||
}
|
||||
|
||||
// 获取指定菜单的徽标数量
|
||||
const getBadgeCount = (path: string): number => {
|
||||
const data = badgeData.value[path]
|
||||
return data?.count || 0
|
||||
}
|
||||
|
||||
// 检查菜单是否应该显示徽标
|
||||
const shouldShowBadge = (path: string): boolean => {
|
||||
const data = badgeData.value[path]
|
||||
return Boolean(data && data.count > 0)
|
||||
}
|
||||
|
||||
// 检查菜单是否应该显示文本徽标
|
||||
const shouldShowTextBadge = (path: string): string | null => {
|
||||
const data = badgeData.value[path]
|
||||
if (data && data.count > 0 && data.type === 'text') {
|
||||
return data.count > 99 ? '99+' : data.count.toString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置页面加载状态
|
||||
const setPageReady = (ready: boolean) => {
|
||||
isPageReady.value = ready
|
||||
|
||||
// 页面加载完成时,加载用户特定的徽标数据
|
||||
if (ready) {
|
||||
loadUserBadgeData()
|
||||
// 处理待处理的更新
|
||||
if (pendingUpdates.value.length > 0) {
|
||||
// console.log('页面加载完成,处理待处理的徽标更新:', pendingUpdates.value.length)
|
||||
pendingUpdates.value.forEach((update) => {
|
||||
updateBadgeData(update.path, update.count, update.type)
|
||||
})
|
||||
pendingUpdates.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新徽标数据
|
||||
const updateBadgeData = (path: string, count: number, type: 'dot' | 'text' = 'text') => {
|
||||
// 如果页面未加载完成,将更新加入队列
|
||||
if (!isPageReady.value) {
|
||||
// console.log('页面未加载完成,将徽标更新加入队列:', { path, count, type })
|
||||
// 检查是否已存在相同路径的待处理更新,避免重复
|
||||
const existingIndex = pendingUpdates.value.findIndex((item) => item.path === path)
|
||||
if (existingIndex >= 0) {
|
||||
pendingUpdates.value[existingIndex] = { path, count, type }
|
||||
} else {
|
||||
pendingUpdates.value.push({ path, count, type })
|
||||
}
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
return
|
||||
}
|
||||
|
||||
// console.log('更新菜单徽标:', { path, count, type })
|
||||
|
||||
if (count <= 0) {
|
||||
// 如果数量为0或负数,移除徽标
|
||||
delete badgeData.value[path]
|
||||
} else {
|
||||
// 更新或创建徽标数据
|
||||
badgeData.value[path] = {
|
||||
path,
|
||||
count,
|
||||
type,
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 批量更新徽标数据
|
||||
const batchUpdateBadgeData = (
|
||||
updates: Array<{ path: string; count: number; type?: 'dot' | 'text' }>
|
||||
) => {
|
||||
if (!isPageReady.value) {
|
||||
// console.log('页面未加载完成,将批量徽标更新加入队列:', updates.length)
|
||||
updates.forEach((update) => {
|
||||
const existingIndex = pendingUpdates.value.findIndex((item) => item.path === update.path)
|
||||
if (existingIndex >= 0) {
|
||||
pendingUpdates.value[existingIndex] = {
|
||||
path: update.path,
|
||||
count: update.count,
|
||||
type: update.type || 'text'
|
||||
}
|
||||
} else {
|
||||
pendingUpdates.value.push({
|
||||
path: update.path,
|
||||
count: update.count,
|
||||
type: update.type || 'text'
|
||||
})
|
||||
}
|
||||
})
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
return
|
||||
}
|
||||
|
||||
// console.log('批量更新菜单徽标:', updates.length)
|
||||
updates.forEach((update) => {
|
||||
updateBadgeData(update.path, update.count, update.type || 'text')
|
||||
})
|
||||
// 批量更新后保存
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 清除指定菜单的徽标
|
||||
const clearBadge = (path: string) => {
|
||||
updateBadgeData(path, 0)
|
||||
}
|
||||
|
||||
// 清除所有徽标
|
||||
const clearAllBadges = () => {
|
||||
badgeData.value = {}
|
||||
pendingUpdates.value = []
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 从菜单列表同步徽标状态
|
||||
const syncBadgeWithMenuList = (menuList: AppRouteRecord[]) => {
|
||||
if (!isPageReady.value) {
|
||||
// console.log('页面未加载完成,跳过菜单徽标同步')
|
||||
return
|
||||
}
|
||||
|
||||
const updateMenuBadge = (menus: AppRouteRecord[]) => {
|
||||
menus.forEach((menu) => {
|
||||
const badgeData = getBadgeData(menu.path)
|
||||
if (badgeData) {
|
||||
// 更新菜单的徽标状态
|
||||
menu.meta.showBadge = badgeData.type === 'dot' && badgeData.count > 0
|
||||
menu.meta.showTextBadge =
|
||||
badgeData.type === 'text' && badgeData.count > 0
|
||||
? badgeData.count > 99
|
||||
? '99+'
|
||||
: badgeData.count.toString()
|
||||
: undefined
|
||||
} else {
|
||||
// 清除菜单的徽标状态
|
||||
menu.meta.showBadge = false
|
||||
menu.meta.showTextBadge = undefined
|
||||
}
|
||||
|
||||
// 递归处理子菜单
|
||||
if (menu.children) {
|
||||
updateMenuBadge(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateMenuBadge(menuList)
|
||||
}
|
||||
|
||||
// 获取待处理更新的数量
|
||||
const getPendingUpdatesCount = () => pendingUpdates.value.length
|
||||
|
||||
// 获取待处理的更新列表
|
||||
const getPendingUpdates = () => [...pendingUpdates.value]
|
||||
|
||||
return {
|
||||
// 状态
|
||||
badgeData,
|
||||
isPageReady,
|
||||
pendingUpdates,
|
||||
|
||||
// 计算属性
|
||||
allBadgeData,
|
||||
|
||||
// 方法
|
||||
getBadgeData,
|
||||
getBadgeCount,
|
||||
shouldShowBadge,
|
||||
shouldShowTextBadge,
|
||||
setPageReady,
|
||||
updateBadgeData,
|
||||
batchUpdateBadgeData,
|
||||
clearBadge,
|
||||
clearAllBadges,
|
||||
syncBadgeWithMenuList,
|
||||
getPendingUpdatesCount,
|
||||
getPendingUpdates,
|
||||
|
||||
// 用户隔离相关方法
|
||||
loadUserBadgeData,
|
||||
saveUserBadgeData,
|
||||
cleanupOtherUserData,
|
||||
getCurrentUserId
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'menu-badge',
|
||||
storage: localStorage
|
||||
// 获取当前用户ID
|
||||
const getCurrentUserId = (): string => {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
return userStore.info?.userId?.toString() || 'anonymous'
|
||||
} catch {
|
||||
return 'anonymous'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 获取用户特定的存储key
|
||||
const getUserStorageKey = (): string => {
|
||||
const userId = getCurrentUserId()
|
||||
return `menu-badge-${userId}`
|
||||
}
|
||||
|
||||
// 从localStorage加载用户特定的徽标数据
|
||||
const loadUserBadgeData = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey()
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
badgeData.value = parsed.badgeData || {}
|
||||
// 注意:不恢复 isPageReady,因为页面状态应该在运行时由代码控制
|
||||
// isPageReady.value 应该在 setPageReady() 中设置,而不是从存储中恢复
|
||||
pendingUpdates.value = parsed.pendingUpdates || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('加载用户徽标数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户特定的徽标数据到localStorage
|
||||
const saveUserBadgeData = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey()
|
||||
const data = {
|
||||
badgeData: badgeData.value,
|
||||
// 注意:不保存 isPageReady,因为页面状态应该在运行时由代码控制
|
||||
// 页面每次加载时都应该重新设置为 true
|
||||
pendingUpdates: pendingUpdates.value
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.warn('保存用户徽标数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理其他用户的徽标数据
|
||||
const cleanupOtherUserData = () => {
|
||||
try {
|
||||
const currentUserId = getCurrentUserId()
|
||||
const keys = Object.keys(localStorage)
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith('menu-badge-') && !key.endsWith(`-${currentUserId}`)) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('清理其他用户数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算属性:获取所有徽标数据
|
||||
const allBadgeData = computed(() => {
|
||||
return { ...badgeData.value }
|
||||
})
|
||||
|
||||
// 获取指定菜单的徽标数据
|
||||
const getBadgeData = (path: string): MenuBadgeData | null => {
|
||||
return badgeData.value[path] || null
|
||||
}
|
||||
|
||||
// 获取指定菜单的徽标数量
|
||||
const getBadgeCount = (path: string): number => {
|
||||
const data = badgeData.value[path]
|
||||
return data?.count || 0
|
||||
}
|
||||
|
||||
// 检查菜单是否应该显示徽标
|
||||
const shouldShowBadge = (path: string): boolean => {
|
||||
const data = badgeData.value[path]
|
||||
return Boolean(data && data.count > 0)
|
||||
}
|
||||
|
||||
// 检查菜单是否应该显示文本徽标
|
||||
const shouldShowTextBadge = (path: string): string | null => {
|
||||
const data = badgeData.value[path]
|
||||
if (data && data.count > 0 && data.type === 'text') {
|
||||
return data.count > 99 ? '99+' : data.count.toString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置页面加载状态
|
||||
const setPageReady = (ready: boolean) => {
|
||||
isPageReady.value = ready
|
||||
|
||||
// 页面加载完成时,加载用户特定的徽标数据
|
||||
if (ready) {
|
||||
loadUserBadgeData()
|
||||
// 确保 isPageReady 保持为 true(防止被其他逻辑覆盖)
|
||||
isPageReady.value = true
|
||||
// 处理待处理的更新
|
||||
if (pendingUpdates.value.length > 0) {
|
||||
pendingUpdates.value.forEach((update) => {
|
||||
updateBadgeData(update.path, update.count, update.type)
|
||||
})
|
||||
pendingUpdates.value = []
|
||||
// 再次确认页面状态
|
||||
isPageReady.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新徽标数据
|
||||
const updateBadgeData = (path: string, count: number, type: 'dot' | 'text' = 'text') => {
|
||||
// 如果页面未加载完成,将更新加入队列
|
||||
if (!isPageReady.value) {
|
||||
// console.log('页面未加载完成,将徽标更新加入队列:', { path, count, type })
|
||||
// 检查是否已存在相同路径的待处理更新,避免重复
|
||||
const existingIndex = pendingUpdates.value.findIndex((item) => item.path === path)
|
||||
if (existingIndex >= 0) {
|
||||
pendingUpdates.value[existingIndex] = { path, count, type }
|
||||
} else {
|
||||
pendingUpdates.value.push({ path, count, type })
|
||||
}
|
||||
console.log('检查菜单是否应该显示徽标:', path, badgeData.value)
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
return
|
||||
}
|
||||
|
||||
if (count <= 0) {
|
||||
// 如果数量为0或负数,移除徽标
|
||||
delete badgeData.value[path]
|
||||
} else {
|
||||
// 更新或创建徽标数据
|
||||
badgeData.value[path] = {
|
||||
path,
|
||||
count,
|
||||
type,
|
||||
lastUpdated: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 批量更新徽标数据
|
||||
const batchUpdateBadgeData = (
|
||||
updates: Array<{ path: string; count: number; type?: 'dot' | 'text' }>
|
||||
) => {
|
||||
if (!isPageReady.value) {
|
||||
updates.forEach((update) => {
|
||||
const existingIndex = pendingUpdates.value.findIndex((item) => item.path === update.path)
|
||||
if (existingIndex >= 0) {
|
||||
pendingUpdates.value[existingIndex] = {
|
||||
path: update.path,
|
||||
count: update.count,
|
||||
type: update.type || 'text'
|
||||
}
|
||||
} else {
|
||||
pendingUpdates.value.push({
|
||||
path: update.path,
|
||||
count: update.count,
|
||||
type: update.type || 'text'
|
||||
})
|
||||
}
|
||||
})
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
return
|
||||
}
|
||||
updates.forEach((update) => {
|
||||
updateBadgeData(update.path, update.count, update.type || 'text')
|
||||
})
|
||||
// 批量更新后保存
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 清除指定菜单的徽标
|
||||
const clearBadge = (path: string) => {
|
||||
updateBadgeData(path, 0)
|
||||
}
|
||||
|
||||
// 清除所有徽标
|
||||
const clearAllBadges = () => {
|
||||
badgeData.value = {}
|
||||
pendingUpdates.value = []
|
||||
// 保存到用户特定存储
|
||||
saveUserBadgeData()
|
||||
}
|
||||
|
||||
// 从菜单列表同步徽标状态
|
||||
const syncBadgeWithMenuList = (menuList: AppRouteRecord[]) => {
|
||||
if (!isPageReady.value) {
|
||||
// console.log('页面未加载完成,跳过菜单徽标同步')
|
||||
return
|
||||
}
|
||||
|
||||
const updateMenuBadge = (menus: AppRouteRecord[]) => {
|
||||
menus.forEach((menu) => {
|
||||
const badgeData = getBadgeData(menu.path)
|
||||
if (badgeData) {
|
||||
// 更新菜单的徽标状态
|
||||
menu.meta.showBadge = badgeData.type === 'dot' && badgeData.count > 0
|
||||
menu.meta.showTextBadge =
|
||||
badgeData.type === 'text' && badgeData.count > 0
|
||||
? badgeData.count > 99
|
||||
? '99+'
|
||||
: badgeData.count.toString()
|
||||
: undefined
|
||||
} else {
|
||||
// 清除菜单的徽标状态
|
||||
menu.meta.showBadge = false
|
||||
menu.meta.showTextBadge = undefined
|
||||
}
|
||||
|
||||
// 递归处理子菜单
|
||||
if (menu.children) {
|
||||
updateMenuBadge(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateMenuBadge(menuList)
|
||||
}
|
||||
|
||||
// 获取待处理更新的数量
|
||||
const getPendingUpdatesCount = () => pendingUpdates.value.length
|
||||
|
||||
// 获取待处理的更新列表
|
||||
const getPendingUpdates = () => [...pendingUpdates.value]
|
||||
|
||||
return {
|
||||
// 状态
|
||||
badgeData,
|
||||
isPageReady,
|
||||
pendingUpdates,
|
||||
|
||||
// 计算属性
|
||||
allBadgeData,
|
||||
|
||||
// 方法
|
||||
getBadgeData,
|
||||
getBadgeCount,
|
||||
shouldShowBadge,
|
||||
shouldShowTextBadge,
|
||||
setPageReady,
|
||||
updateBadgeData,
|
||||
batchUpdateBadgeData,
|
||||
clearBadge,
|
||||
clearAllBadges,
|
||||
syncBadgeWithMenuList,
|
||||
getPendingUpdatesCount,
|
||||
getPendingUpdates,
|
||||
|
||||
// 用户隔离相关方法
|
||||
loadUserBadgeData,
|
||||
saveUserBadgeData,
|
||||
cleanupOtherUserData,
|
||||
getCurrentUserId
|
||||
}
|
||||
})
|
||||
|
||||
+21
-10
@@ -140,7 +140,7 @@ const handleMessage = (event: MessageEvent) => {
|
||||
isOnUserCenter = currentPath.startsWith('/admin/user/user_info')
|
||||
// console.warn('Router currentRoute 未初始化,使用 window.location 作为备选方案')
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Router 完全未初始化,使用 window.location 作为备选方案
|
||||
currentPath = window.location.pathname || ''
|
||||
isOnMobileSignature = currentPath.startsWith('/auth/signature/mobile')
|
||||
@@ -230,7 +230,7 @@ const handleMessage = (event: MessageEvent) => {
|
||||
isAlertShowing = false
|
||||
})
|
||||
} else if (shouldShowAlert && isAlertShowing) {
|
||||
console.log('当前已有弹出框显示,跳过新的提示框')
|
||||
// 已有弹窗显示,避免重复弹出
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,10 +254,17 @@ const handleMessage = (event: MessageEvent) => {
|
||||
|
||||
// 处理审批相关消息
|
||||
if (data.event && data.event.includes('approval')) {
|
||||
// console.log('收到审批相关消息:', data)
|
||||
// 如果是审批待办数量消息,直接触发徽标更新
|
||||
if (data.event.includes('.todo.counts') || data.event.includes('.badge.counts')) {
|
||||
// 检查是否是数组格式的徽标数据或待办数量消息
|
||||
if (
|
||||
Array.isArray(data.data) ||
|
||||
Array.isArray(data) ||
|
||||
data.event.includes('.todo.counts') ||
|
||||
data.event.includes('.badge.counts')
|
||||
) {
|
||||
// 数组格式的徽标数据或待办数量消息,触发菜单徽标更新事件
|
||||
mittBus.emit('menu-badge-update', data)
|
||||
// 标记已处理,避免在后续通用处理中重复处理
|
||||
data._badgeProcessed = true
|
||||
} else {
|
||||
// 其他审批消息触发审批徽标更新事件
|
||||
mittBus.emit('approval-badge-update', data)
|
||||
@@ -279,10 +286,15 @@ const handleMessage = (event: MessageEvent) => {
|
||||
}
|
||||
|
||||
// 处理其他可能的徽标相关消息
|
||||
if (data.event && (data.event.includes('badge') || data.event.includes('count'))) {
|
||||
// console.log('收到其他徽标相关消息:', data)
|
||||
// 尝试作为通用徽标更新处理
|
||||
mittBus.emit('menu-badge-update', data)
|
||||
// 检查是否是数组格式的徽标数据(无论是否有 event 字段)
|
||||
// 如果已经被处理过(如审批消息),则跳过
|
||||
if (!data._badgeProcessed) {
|
||||
if (Array.isArray(data) || (data.data && Array.isArray(data.data))) {
|
||||
mittBus.emit('menu-badge-update', data)
|
||||
} else if (data.event && (data.event.includes('badge') || data.event.includes('count'))) {
|
||||
// 尝试作为通用徽标更新处理
|
||||
mittBus.emit('menu-badge-update', data)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送原始消息到事件总线
|
||||
@@ -357,7 +369,6 @@ const scheduleReconnect = () => {
|
||||
|
||||
cleanup()
|
||||
const delay = getReconnectDelay()
|
||||
console.log(`WebSocket重连尝试 ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS},延迟: ${delay}ms`)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
const { accessToken } = useUserStore()
|
||||
if (accessToken && !isConnecting) {
|
||||
|
||||
@@ -638,10 +638,25 @@
|
||||
if (c.type === 'initiator_leader') {
|
||||
group.users = []
|
||||
} else if (c.value) {
|
||||
group.users = String(c.value)
|
||||
// group.users = String(c.value)
|
||||
// .split(',')
|
||||
// .map((v) => Number((v || '').trim()))
|
||||
// .filter((n) => !Number.isNaN(n))
|
||||
const values = String(c.value)
|
||||
.split(',')
|
||||
.map((v) => Number((v || '').trim()))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
.map((v) => (v || '').trim())
|
||||
.filter(Boolean)
|
||||
// 判断是否为纯数字ID
|
||||
const isNumericIds = values.every(
|
||||
(v) => !Number.isNaN(Number(v)) && Number.isInteger(Number(v))
|
||||
)
|
||||
if (isNumericIds) {
|
||||
// 如果是数字ID,转换为数字数组
|
||||
group.users = values.map((v) => Number(v))
|
||||
} else {
|
||||
// 如果是字符串名称,保持为字符串数组
|
||||
group.users = values
|
||||
}
|
||||
}
|
||||
node.ccGroups.push(group)
|
||||
}
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
const processFiles = (files: any[], type: string) => {
|
||||
if (!files || !Array.isArray(files)) return []
|
||||
return files.map((file: any) => ({
|
||||
name: `${type}文件`,
|
||||
name: file.original_name,
|
||||
url: file.file_full_url || file.file_url,
|
||||
file_name: file.file_url
|
||||
}))
|
||||
|
||||
@@ -937,6 +937,8 @@
|
||||
pending: h?.status != 'approved' && h?.status != 'rejected' && h?.status != 'submitted',
|
||||
comment: h?.comment || h?.remark || ''
|
||||
}))
|
||||
} else {
|
||||
approvalRecords.value = []
|
||||
}
|
||||
timelineDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
const processFiles = (files: any[], type: string) => {
|
||||
if (!files || !Array.isArray(files)) return []
|
||||
return files.map((file: any) => ({
|
||||
name: `${type}文件`,
|
||||
name: file.original_name,
|
||||
url: file.file_full_url || file.file_url,
|
||||
file_name: file.file_url
|
||||
}))
|
||||
@@ -499,6 +499,8 @@
|
||||
pending: h?.status != 'approved' && h?.status != 'rejected' && h?.status != 'submitted',
|
||||
comment: h?.comment || h?.remark || ''
|
||||
}))
|
||||
} else {
|
||||
approvalRecords.value = []
|
||||
}
|
||||
timelineDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
const processFiles = (files: any[], type: string) => {
|
||||
if (!files || !Array.isArray(files)) return []
|
||||
return files.map((file: any) => ({
|
||||
name: `${type}文件`,
|
||||
name: file.original_name,
|
||||
url: file.file_full_url || file.file_url,
|
||||
file_name: file.file_url
|
||||
}))
|
||||
@@ -499,6 +499,8 @@
|
||||
pending: h?.status != 'approved' && h?.status != 'rejected' && h?.status != 'submitted',
|
||||
comment: h?.comment || h?.remark || ''
|
||||
}))
|
||||
} else {
|
||||
approvalRecords.value = []
|
||||
}
|
||||
timelineDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
<el-table-column prop="name" label="名字" align="center" />
|
||||
<el-table-column prop="phone" label="电话" align="center" />
|
||||
<el-table-column prop="email" label="邮箱" align="center" />
|
||||
<el-table-column prop="report_to_name" label="直属上级" align="center" />
|
||||
<el-table-column prop="corp_name" label="所属公司" align="center" />
|
||||
<el-table-column prop="follow" label="状态" align="center">
|
||||
<template #default="scope">
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<span>{{ account.name }}</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="company" label="系统">
|
||||
<ElSelect v-model="formData.systems" class="account-select" @change="changesystems">
|
||||
<ElOption
|
||||
|
||||
@@ -233,7 +233,7 @@
|
||||
const processFiles = (files: any[], type: string) => {
|
||||
if (!files || !Array.isArray(files)) return []
|
||||
return files.map((file: any) => ({
|
||||
name: `${type}文件`,
|
||||
name: file.original_name,
|
||||
url: file.file_full_url || file.file_url,
|
||||
file_name: file.file_url
|
||||
}))
|
||||
|
||||
@@ -875,7 +875,13 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.contract = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.contract = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- 上传PI -->
|
||||
@@ -890,7 +896,13 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.pi_doc = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.pi_doc = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- 上传水单 -->
|
||||
@@ -905,7 +917,13 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.transaction_slip = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.transaction_slip = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
@@ -1039,9 +1057,21 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { CrmService } from '@/api/crmApi'
|
||||
import { useStockingForm } from '@/composables/useStockingForm'
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted, ref, computed, toRaw, watch, onBeforeUnmount } from 'vue'
|
||||
import MultiFileUpload from '@/components/core/forms/MultiFileUpload.vue'
|
||||
import contractIcon from '@/assets/img/crm/contract.png'
|
||||
|
||||
// 本地草稿缓存(用于切换页面回显,关闭页面时清空)
|
||||
const STORAGE_VERSION = 'v1'
|
||||
const userStoreForDraft = useUserStore()
|
||||
const storageUserId = userStoreForDraft?.info?.userId || 'anonymous'
|
||||
const draftKeyBase = `stocking:add:draft:${STORAGE_VERSION}:${storageUserId}`
|
||||
const routeForDraft = useRoute()
|
||||
const draftKey = computed(() => {
|
||||
// 若存在复制/编辑 id,区分不同草稿;否则使用路径
|
||||
const id = routeForDraft.query?.id
|
||||
return id ? `${draftKeyBase}:${id}` : `${draftKeyBase}:${routeForDraft.fullPath}`
|
||||
})
|
||||
|
||||
defineOptions({ name: 'CrmStockingAdd' }) // 定义组件名称,用于 KeepAlive 缓存控制
|
||||
|
||||
@@ -1173,6 +1203,69 @@
|
||||
handleVehicleCurrentChange,
|
||||
chooseVehicle
|
||||
} = useStockingForm(false) // isFinal = false
|
||||
|
||||
// ---------- 草稿缓存:保存/恢复/清理 ----------
|
||||
const DRAFT_DEBOUNCE_MS = 500
|
||||
let draftTimer: number | any = null
|
||||
|
||||
const pickDraftPayload = () => ({
|
||||
baseForm: toRaw(baseForm),
|
||||
items: toRaw(items.value),
|
||||
paymentItems: toRaw(paymentItems.value),
|
||||
contractFiles: toRaw(contractFiles.value),
|
||||
piFiles: toRaw(piFiles.value),
|
||||
waterBillFiles: toRaw(waterBillFiles.value)
|
||||
})
|
||||
|
||||
const saveDraft = () => {
|
||||
try {
|
||||
const payload = pickDraftPayload()
|
||||
localStorage.setItem(draftKey.value, JSON.stringify(payload))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const scheduleSaveDraft = () => {
|
||||
if (draftTimer) clearTimeout(draftTimer)
|
||||
draftTimer = setTimeout(() => {
|
||||
saveDraft()
|
||||
}, DRAFT_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const loadDraft = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey.value)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
// 合并到现有表单数据
|
||||
if (parsed?.baseForm && typeof parsed.baseForm === 'object') {
|
||||
Object.assign(baseForm, parsed.baseForm)
|
||||
}
|
||||
if (Array.isArray(parsed?.items)) {
|
||||
items.value = parsed.items
|
||||
}
|
||||
if (Array.isArray(parsed?.paymentItems)) {
|
||||
paymentItems.value = parsed.paymentItems
|
||||
}
|
||||
if (Array.isArray(parsed?.contractFiles)) {
|
||||
contractFiles.value = parsed.contractFiles
|
||||
baseForm.contract = (contractFiles.value || []).map((f: any) => f.file_name)
|
||||
}
|
||||
if (Array.isArray(parsed?.piFiles)) {
|
||||
piFiles.value = parsed.piFiles
|
||||
baseForm.pi_doc = (piFiles.value || []).map((f: any) => f.file_name)
|
||||
}
|
||||
if (Array.isArray(parsed?.waterBillFiles)) {
|
||||
waterBillFiles.value = parsed.waterBillFiles
|
||||
baseForm.transaction_slip = (waterBillFiles.value || []).map((f: any) => f.file_name)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const clearDraft = () => {
|
||||
try {
|
||||
localStorage.removeItem(draftKey.value)
|
||||
} catch {}
|
||||
}
|
||||
// 获取详情数据
|
||||
const getdetilfn = async () => {
|
||||
const res = await CrmService.getStockDetail({ id: stocking_id })
|
||||
@@ -1197,6 +1290,8 @@
|
||||
}
|
||||
// 如果校验通过,调用原始的handleSubmit方法
|
||||
await handleSubmit(isFinal)
|
||||
// 提交成功后清理草稿
|
||||
clearDraft()
|
||||
} catch (error) {
|
||||
console.log('表单校验失败:', error)
|
||||
}
|
||||
@@ -1209,6 +1304,27 @@
|
||||
if (stocking_id) {
|
||||
await getdetilfn()
|
||||
}
|
||||
// 尝试从草稿恢复
|
||||
loadDraft()
|
||||
// 监听表单变化,自动保存草稿
|
||||
watch(
|
||||
() => [baseForm, items.value, paymentItems.value],
|
||||
() => scheduleSaveDraft(),
|
||||
{ deep: true }
|
||||
)
|
||||
watch(contractFiles, scheduleSaveDraft, { deep: true })
|
||||
watch(piFiles, scheduleSaveDraft, { deep: true })
|
||||
watch(waterBillFiles, scheduleSaveDraft, { deep: true })
|
||||
// 浏览器标签关闭时清理草稿
|
||||
const beforeUnload = () => {
|
||||
clearDraft()
|
||||
}
|
||||
window.addEventListener('beforeunload', beforeUnload)
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', beforeUnload)
|
||||
// 当页面真正被卸载(如关闭当前路由标签)时清理草稿
|
||||
clearDraft()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -866,10 +866,15 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.contract = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.contract = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传PI -->
|
||||
<div class="boxshadow">
|
||||
<div class="form-title">
|
||||
@@ -882,10 +887,15 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.pi_doc = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.pi_doc = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传水单 -->
|
||||
<div class="boxshadow">
|
||||
<div class="form-title">
|
||||
@@ -898,7 +908,13 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.transaction_slip = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.transaction_slip = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
|
||||
@@ -1005,10 +1005,15 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.contract = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.contract = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传PI -->
|
||||
<div class="boxshadow">
|
||||
<div class="form-title">
|
||||
@@ -1021,10 +1026,15 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.pi_doc = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.pi_doc = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传水单 -->
|
||||
<div class="boxshadow">
|
||||
<div class="form-title">
|
||||
@@ -1037,7 +1047,13 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.transaction_slip = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.transaction_slip = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
|
||||
@@ -228,6 +228,8 @@
|
||||
pending: h?.status != 'approved' && h?.status != 'rejected' && h?.status != 'submitted',
|
||||
comment: h?.comment || h?.remark || ''
|
||||
}))
|
||||
} else {
|
||||
approvalRecords.value = []
|
||||
}
|
||||
timelineDialogVisible.value = true
|
||||
}
|
||||
@@ -312,7 +314,7 @@
|
||||
const processFiles = (files: any[], type: string) => {
|
||||
if (!files || !Array.isArray(files)) return []
|
||||
return files.map((file: any) => ({
|
||||
name: `${type}文件`,
|
||||
name: file.original_name,
|
||||
url: file.file_full_url || file.file_url,
|
||||
file_name: file.file_url
|
||||
}))
|
||||
|
||||
@@ -869,10 +869,15 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.contract = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.contract = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传PI -->
|
||||
<div class="boxshadow">
|
||||
<div class="form-title">
|
||||
@@ -885,10 +890,15 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.pi_doc = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.pi_doc = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 上传水单 -->
|
||||
<div class="boxshadow">
|
||||
<div class="form-title">
|
||||
@@ -901,7 +911,13 @@
|
||||
hint-text="支持多文件上传"
|
||||
:icon-src="contractIcon"
|
||||
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
@change="(files) => (baseForm.transaction_slip = files.map((f) => f.file_name))"
|
||||
@change="
|
||||
(files) =>
|
||||
(baseForm.transaction_slip = files.map((f) => ({
|
||||
url: f.file_name,
|
||||
original_name: f.name
|
||||
})))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-card">
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
<i class="iconfont-sys"></i> 作废
|
||||
</el-link>
|
||||
<el-link
|
||||
v-if="scope.row.approval_status == 3 || scope.row.approval_status == 4"
|
||||
v-if="scope.row.approval_status != 1 && scope.row.approval_status != 2"
|
||||
type="danger"
|
||||
underline="never"
|
||||
@click="stockdeletefn(scope.row)"
|
||||
@@ -293,6 +293,8 @@
|
||||
pending: h?.status != 'approved' && h?.status != 'rejected' && h?.status != 'submitted',
|
||||
comment: h?.comment || h?.remark || ''
|
||||
}))
|
||||
} else {
|
||||
approvalRecords.value = []
|
||||
}
|
||||
timelineDialogVisible.value = true
|
||||
}
|
||||
@@ -389,7 +391,7 @@
|
||||
const processFiles = (files: any[], type: string) => {
|
||||
if (!files || !Array.isArray(files)) return []
|
||||
return files.map((file: any) => ({
|
||||
name: `${type}文件`,
|
||||
name: file.original_name,
|
||||
url: file.file_full_url || file.file_url,
|
||||
file_name: file.file_url
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user