feat: 导入增强 + 客户合并 + 同伴自定义 + 性能优化
=== 导入系统全面增强 === - 5 Sheet 完整导入:拜访/计划/商机/要客/纪要 - 同访人智能解析:中英文逗号/顿号/分号 → 系统用户UUID + 外部人员TEXT - 客户自动创建:Excel中不存在的客户自动入库 - 模糊名称匹配:去空格 + 包含关系纠错 - manager_id 修正:导入时用客户分配的经理(非Excel列/非导入人) - 模板更新:Sheet5「客户经理」→「填报人」,同访人示例含逗号分隔 === 客户合并功能 === - PUT 改名碰撞检测 → 409 + 合并预览 - GET merge-preview / POST merge 端点 - 事务级迁移:Visit/WorkPlan/MiniBusiness/KeyVisit/联系人/分配 - 去重逻辑:联系人(name+phone)、分配(manager+role) - last_visit_date 取最大值 === 拜访记录完善 === - visits 新增 companion_names TEXT[] 列 - 同访人支持自定义输入(外部人员),el-select allow-create - 移动端 VisitForm + PC端 ManagerWorkspace 统一 - 周报「客户经理」→「相关人员」(创建人+同访人) - 纪要「客户经理」→「填报人」 - 删除拜访后重新计算 customer.last_visit_date === 客户选择放开 === - 客户经理可看到全部客户(不再限自己分配的) - 拜访时客户下拉返回全量 === 前端性能优化 === - Element Plus 按需加载 (unplugin-vue-components + unplugin-element-plus) - 周报照片URL并行请求 (Promise.all) - onMounted 三路并行 (loadReport + dropdowns + AI) - nginx Cache-Control: immutable for /assets/ - Google Fonts preconnect hints - 图片上传前 Canvas 压缩 (max 1920px, quality 0.8) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Client-side image compression before MinIO upload.
|
||||
* Uses Canvas API to resize and re-encode images, reducing
|
||||
* storage/bandwidth costs and improving load times.
|
||||
*/
|
||||
export interface CompressOptions {
|
||||
/** Max dimension (width or height) in pixels. Default 1920. */
|
||||
maxPixels?: number
|
||||
/** JPEG quality 0–1. Default 0.8. */
|
||||
quality?: number
|
||||
/** Max file size in bytes before compression is applied. Default 200KB. */
|
||||
sizeThreshold?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress an image file if it exceeds the size/dimension thresholds.
|
||||
* Returns a File-like object suitable for upload, or the original file
|
||||
* if compression is not needed.
|
||||
*/
|
||||
export async function compressImage(
|
||||
file: File,
|
||||
options: CompressOptions = {},
|
||||
): Promise<File> {
|
||||
const { maxPixels = 1920, quality = 0.8, sizeThreshold = 200 * 1024 } = options
|
||||
|
||||
// Skip non-image files
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
// Don't re-compress GIF/SVG
|
||||
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file
|
||||
|
||||
// Skip small files
|
||||
if (file.size <= sizeThreshold) return file
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
let { width, height } = img
|
||||
|
||||
// Skip if already within dimension limits and file is small enough
|
||||
if (width <= maxPixels && height <= maxPixels && file.size <= sizeThreshold * 2) {
|
||||
return resolve(file)
|
||||
}
|
||||
|
||||
// Calculate new dimensions maintaining aspect ratio
|
||||
if (width > maxPixels || height > maxPixels) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * maxPixels) / width)
|
||||
width = maxPixels
|
||||
} else {
|
||||
width = Math.round((width * maxPixels) / height)
|
||||
height = maxPixels
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
return resolve(file) // fallback
|
||||
}
|
||||
|
||||
// Use better image smoothing
|
||||
ctx.imageSmoothingEnabled = true
|
||||
ctx.imageSmoothingQuality = 'medium'
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Use original MIME type if supported, otherwise JPEG
|
||||
let mimeType = file.type
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(mimeType)) {
|
||||
mimeType = 'image/jpeg'
|
||||
}
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob || blob.size >= file.size) {
|
||||
// Compression didn't help or failed — use original
|
||||
return resolve(file)
|
||||
}
|
||||
const compressed = new File([blob], file.name, {
|
||||
type: mimeType,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
resolve(compressed)
|
||||
},
|
||||
mimeType,
|
||||
quality,
|
||||
)
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(file) // fallback to original on error
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user