This commit is contained in:
zlwu committed 2026-08-09 21:07:28 +08:00
1 parent b45ce2c32c
commit a5210a6a9e
30 files changed
+11989 -5917

No files matched your search

+10 -5
View File
@@ -60,10 +60,12 @@ const emit = defineEmits<{
}>()
const fileList = ref<UploadFile[]>([])
/** 本地 URL 列表,避免多文件并行上传完成时基于 stale props 互相覆盖 */
const localUrls = ref<string[]>([])
const isDirectUrl = (value: string) => /^https?:\/\//i.test(value)
const displayItems = computed(() => parseValue(props.modelValue))
const displayItems = computed(() => localUrls.value)
const fileAccept = computed(() => props.fileType.map((type) => `.${type}`).join(','))
@@ -83,7 +85,7 @@ function listToString(items: string[]) {
}
function syncFileListFromModel() {
fileList.value = displayItems.value.map((url, index) => ({
fileList.value = localUrls.value.map((url, index) => ({
name: url.split('/').pop() || `file-${index + 1}`,
url,
status: 'success'
@@ -92,7 +94,8 @@ function syncFileListFromModel() {
watch(
() => props.modelValue,
() => {
(value) => {
localUrls.value = parseValue(value)
if (props.disabled) return
syncFileListFromModel()
},
@@ -124,7 +127,8 @@ async function handleUploadRequest(options: UploadRequestOptions) {
if (!fileUrl) {
throw new Error('上传失败')
}
const next = [...displayItems.value, fileUrl]
const next = [...localUrls.value, fileUrl]
localUrls.value = next
emit('update:modelValue', listToString(next))
options.onSuccess?.(res)
} catch (error: any) {
@@ -136,7 +140,8 @@ async function handleUploadRequest(options: UploadRequestOptions) {
function handleRemove(uploadFile: UploadFile) {
const removedUrl = uploadFile.url || (uploadFile.response as { fileUrl?: string })?.fileUrl
if (!removedUrl) return
const next = displayItems.value.filter((url) => url !== removedUrl)
const next = localUrls.value.filter((url) => url !== removedUrl)
localUrls.value = next
emit('update:modelValue', listToString(next))
}
@@ -123,7 +123,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
Plus,
Loading,
@@ -177,13 +177,23 @@
const isPointerInside = ref(false)
const isDragOver = ref(false)
let dragEnterDepth = 0
let clearUploadScheduled = false
/**
* 本地缓冲:el-upload 多选会同步连续触发 @change;
* 若基于尚未回写的 props.modelValue 展开累加,会只留下最后一个文件。
*/
const files = ref<BxAttachmentItem[]>([])
const files = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
watch(
() => props.modelValue,
(val) => {
files.value = Array.isArray(val) ? val.slice() : []
},
{ immediate: true }
)
function syncFiles(next: BxAttachmentItem[]) {
files.value = next
emit('update:modelValue', next)
}
@@ -256,12 +266,19 @@
}
}
async function handleAttachmentChange(uploadFile: UploadFile) {
const rawFile = uploadFile.raw
if (!rawFile) return
await uploadRawFile(rawFile)
async function scheduleClearUploadFiles() {
if (clearUploadScheduled) return
clearUploadScheduled = true
await nextTick()
uploadRef.value?.clearFiles()
clearUploadScheduled = false
}
function handleAttachmentChange(uploadFile: UploadFile) {
const rawFile = uploadFile.raw
if (!rawFile) return
void uploadRawFile(rawFile)
void scheduleClearUploadFiles()
}
function handlePointerEnter() {
@@ -95,12 +95,16 @@
const isPointerInside = ref(false)
const isDragOver = ref(false)
let dragEnterDepth = 0
let clearUploadScheduled = false
const previewUrlMap = ref<Record<string, string>>({})
const files = computed(() => props.modelValue || [])
/**
* 本地缓冲:el-upload 多选时会同步连续触发多次 @change,
* 若每次都基于尚未回写的 props.modelValue 累加,最终只会留下最后一个文件。
*/
const localFiles = ref<File[]>([])
const displayItems = computed<DisplayItem[]>(() =>
files.value.map((file, index) => {
localFiles.value.map((file, index) => {
const key = `${file.name}-${file.size}-${file.lastModified}-${index}`
return {
key,
@@ -119,7 +123,7 @@
function rebuildPreviews() {
revokeAllPreviews()
const next: Record<string, string> = {}
files.value.forEach((file, index) => {
localFiles.value.forEach((file, index) => {
if (!file.type.startsWith('image/')) return
const key = `${file.name}-${file.size}-${file.lastModified}-${index}`
next[key] = URL.createObjectURL(file)
@@ -129,12 +133,17 @@
watch(
() => props.modelValue,
() => rebuildPreviews(),
{ deep: true, immediate: true }
(val) => {
localFiles.value = Array.isArray(val) ? val.slice() : []
rebuildPreviews()
},
{ immediate: true }
)
function syncFiles(next: File[]) {
localFiles.value = next
emit('update:modelValue', next)
rebuildPreviews()
}
function isFileAccepted(file: File) {
@@ -181,7 +190,7 @@
function appendFiles(incoming: File[]) {
if (!incoming.length || props.disabled) return
const next = [...files.value]
const next = [...localFiles.value]
for (const raw of incoming) {
if (next.length >= props.limit) {
ElMessage.warning(`最多上传 ${props.limit} 个文件`)
@@ -196,16 +205,23 @@
syncFiles(next)
}
async function onChange(uploadFile: UploadFile) {
async function scheduleClearUploadFiles() {
if (clearUploadScheduled) return
clearUploadScheduled = true
await nextTick()
uploadRef.value?.clearFiles()
clearUploadScheduled = false
}
function onChange(uploadFile: UploadFile) {
const raw = uploadFile.raw
if (!raw) return
appendFiles([raw])
await nextTick()
uploadRef.value?.clearFiles()
void scheduleClearUploadFiles()
}
function removeAt(index: number) {
const next = [...files.value]
const next = [...localFiles.value]
next.splice(index, 1)
syncFiles(next)
}
@@ -21,6 +21,7 @@
type="button"
class="invoice-attach-card"
:class="{ 'is-selected': selectedKeys.has(item.key) }"
:style="{ '--invoice-group-color': invoiceGroupColor(item.invoiceId) }"
@click="toggleOne(item.key)"
>
<span class="invoice-attach-card__index">{{ item.serial }}</span>
@@ -48,7 +49,7 @@
</span>
<div class="invoice-attach-card__footer">
<span class="invoice-attach-card__amount">
{{ formatMoneyYuan(item.amount) }} 元
整票 {{ formatMoneyYuan(item.amount) }} 元
</span>
<el-icon
class="invoice-attach-card__download"
@@ -179,6 +180,25 @@
return `${y}年${Number(m)}月`
}
/** 同一发票(同一次上传)共用边框色,不同发票互相区分 */
const INVOICE_GROUP_COLORS = [
'#0891b2',
'#7c3aed',
'#d97706',
'#db2777',
'#2563eb',
'#059669',
'#ea580c',
'#4f46e5'
]
function invoiceGroupColor(invoiceId: string | number) {
const s = String(invoiceId || '')
let hash = 0
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) >>> 0
return INVOICE_GROUP_COLORS[hash % INVOICE_GROUP_COLORS.length]
}
function invoiceAttachments(row: any): BxAttachmentItem[] {
const list = Array.isArray(row?.attachments) ? row.attachments : []
if (list.length) {
@@ -314,37 +334,34 @@
position: relative;
width: 140px;
padding: 0;
border: 1px solid var(--border, #d9e8e2);
border: 2px solid var(--invoice-group-color, var(--border, #d9e8e2));
border-radius: 8px;
background: var(--surface, #fff);
overflow: hidden;
cursor: pointer;
text-align: left;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
transition: box-shadow 0.15s ease;
&:hover {
border-color: var(--primary, #10b981);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.12);
}
&.is-selected {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
}
}
.invoice-attach-card__index {
position: absolute;
z-index: 1;
top: 6px;
left: 8px;
font-size: 12px;
z-index: 2;
top: 0;
left: 0;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 8px 0 8px 0;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
color: var(--primary, #10b981);
background: none;
line-height: 20px;
text-align: center;
}
.invoice-attach-card__preview {