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:
2026-06-29 22:25:04 +08:00
parent b343970ecc
commit 3285e22142
23 changed files with 1629 additions and 172 deletions
+4 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { computed } from 'vue'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
const auth = useAuthStore()
const isMobile = computed(() => {
@@ -9,7 +10,9 @@ const isMobile = computed(() => {
</script>
<template>
<router-view />
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<style>
+52
View File
@@ -0,0 +1,52 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
DesktopLayout: typeof import('./components/DesktopLayout.vue')['default']
EditLogPanel: typeof import('./components/EditLogPanel.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElLink: typeof import('element-plus/es')['ElLink']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimePicker: typeof import('element-plus/es')['ElTimePicker']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ImagePreview: typeof import('./components/ImagePreview.vue')['default']
MobileLayout: typeof import('./components/MobileLayout.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}
-4
View File
@@ -1,8 +1,5 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import './tailwind.css'
import App from './App.vue'
import router from './router'
@@ -10,5 +7,4 @@ import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn as any })
app.mount('#app')
+101
View File
@@ -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 01. 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
}
})
}
+69 -1
View File
@@ -144,7 +144,44 @@ async function handleSubmit() {
}
dialogVisible.value = false
await loadCustomers()
} catch (e: any) { ElMessage.error('操作失败: ' + (e.response?.data?.detail || e.message)) }
} catch (e: any) {
// Handle name collision → offer merge
if (e.response?.status === 409 && e.response?.data?.detail?.preview) {
const d = e.response.data.detail
mergeSourceId.value = d.source_id
mergeSourceName.value = d.source_name
mergeTargetId.value = d.target_id
mergeTargetName.value = d.target_name
mergePreview.value = d.preview
mergeDialogVisible.value = true
return
}
ElMessage.error('操作失败: ' + (typeof e.response?.data?.detail === 'object' ? e.response.data.detail.message : (e.response?.data?.detail || e.message)))
}
}
// ── Merge ──
const mergeDialogVisible = ref(false)
const mergeSourceId = ref('')
const mergeSourceName = ref('')
const mergeTargetId = ref('')
const mergeTargetName = ref('')
const mergePreview = ref<any>({})
const mergeLoading = ref(false)
async function handleMerge() {
mergeLoading.value = true
try {
const res = await api.post(`/customers/${mergeSourceId.value}/merge`, { target_id: mergeTargetId.value })
ElMessage.success(res.data.result || '合并完成')
mergeDialogVisible.value = false
dialogVisible.value = false
await loadCustomers()
} catch (e: any) {
ElMessage.error('合并失败: ' + (e.response?.data?.detail || e.message))
} finally {
mergeLoading.value = false
}
}
async function removeExistingContact(contactId: string) {
@@ -413,6 +450,37 @@ async function handleImport() {
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</el-button>
</template>
</el-dialog>
<!-- Merge confirmation dialog -->
<el-dialog v-model="mergeDialogVisible" title="合并客户" width="520px" :close-on-click-modal="false">
<div style="line-height:1.8">
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
客户<b>{{ mergeTargetName }}</b>已存在是否将<b>{{ mergeSourceName }}</b>合并到{{ mergeTargetName }}
</el-alert>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="源客户">{{ mergeSourceName }}</el-descriptions-item>
<el-descriptions-item label="目标客户">{{ mergeTargetName }}</el-descriptions-item>
<el-descriptions-item label="拜访记录">{{ mergePreview.visits || 0 }} </el-descriptions-item>
<el-descriptions-item label="工作计划">{{ mergePreview.work_plans || 0 }} </el-descriptions-item>
<el-descriptions-item label="商机跟单">{{ mergePreview.mini_business || 0 }} </el-descriptions-item>
<el-descriptions-item label="要客拜访">{{ mergePreview.key_visits || 0 }} </el-descriptions-item>
<el-descriptions-item label="联系人">{{ mergePreview.contacts || 0 }} </el-descriptions-item>
<el-descriptions-item label="经理分配">{{ mergePreview.assignments || 0 }} </el-descriptions-item>
</el-descriptions>
<div v-if="mergePreview.note" style="margin-top:12px; color:var(--amber); font-size:13px">
{{ mergePreview.note }}
</div>
<div style="margin-top:12px; color:var(--vermilion); font-size:13px">
合并后{{ mergeSourceName }}将被删除此操作不可撤销
</div>
</div>
<template #footer>
<el-button @click="mergeDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="mergeLoading" @click="handleMerge">确认合并</el-button>
</template>
</el-dialog>
</div>
</template>
+2 -82
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { dashboardApi } from '@/api/dashboard'
@@ -21,51 +21,6 @@ const planDate = ref(todayStr())
const planContent = ref('')
const planSaving = ref(false)
// ── Batch selection ──
const selectedCards = ref<Set<string>>(new Set())
const batchDialogVisible = ref(false)
const batchPlanDate = ref(todayStr())
const batchPlanContent = ref('')
const batchSaving = ref(false)
watch(expandedManagers, () => { selectedCards.value.clear() })
function toggleCardSelect(customerId: string) {
const s = new Set(selectedCards.value)
if (s.has(customerId)) s.delete(customerId)
else s.add(customerId)
selectedCards.value = s
}
function openBatchDialog() {
if (selectedCards.value.size === 0) { ElMessage.warning('请先勾选客户卡片'); return }
batchPlanContent.value = ''
batchPlanDate.value = todayStr()
batchDialogVisible.value = true
}
async function handleBatchCreate() {
if (!batchPlanContent.value.trim()) { ElMessage.warning('请输入计划内容'); return }
batchSaving.value = true
let created = 0
for (const cid of selectedCards.value) {
try {
await api.post('/work-plans/', {
customer_id: cid,
plan_content: batchPlanContent.value.trim(),
plan_date: batchPlanDate.value,
status: '计划中',
})
created++
} catch (_) { /* continue */ }
}
ElMessage.success(`已为 ${created} 个客户制定计划`)
batchDialogVisible.value = false
selectedCards.value.clear()
batchSaving.value = false
await loadData()
}
const referenceMonth = computed(() => {
const d = new Date()
d.setMonth(d.getMonth() + monthOffset.value)
@@ -204,14 +159,7 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
</div>
<div v-if="expandedManagers.has(m.manager_id)" class="customer-grid">
<div class="batch-actions" v-if="selectedCards.size > 0" style="width:100%;margin-bottom:8px">
<el-button type="primary" size="small" @click="openBatchDialog">📋 批量制定计划 ({{ selectedCards.size }})</el-button>
<el-button size="small" @click="selectedCards.clear()">取消选择</el-button>
</div>
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="['customer-card--' + cust.status, { 'card-selected': selectedCards.has(cust.customer_id) }]" @click="openCustomerDialog(cust)">
<div v-if="cust.status !== 'green' && cust.status !== 'gray'" class="card-check" @click.stop="toggleCardSelect(cust.customer_id)">
<span v-if="selectedCards.has(cust.customer_id)"></span><span v-else></span>
</div>
<div v-for="cust in m.customers" :key="cust.customer_id" class="customer-card" :class="`customer-card--${cust.status}`" @click="openCustomerDialog(cust)">
<div class="card-status-stripe"></div>
<div class="card-body">
<div class="card-name-row">
@@ -322,23 +270,6 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
</el-link>
</div>
</el-dialog>
<!-- Batch Plan Dialog -->
<el-dialog v-model="batchDialogVisible" title="批量制定拜访计划" width="480px">
<p style="margin:0 0 12px;color:var(--c-text-muted)">将为 <strong>{{ selectedCards.size }}</strong> 个客户统一制定拜访计划</p>
<el-form label-position="top">
<el-form-item label="计划时间">
<el-date-picker v-model="batchPlanDate" type="date" style="width:100%" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="计划内容">
<el-input v-model="batchPlanContent" type="textarea" :rows="3" placeholder="统一的拜访计划内容" :disabled="batchSaving" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="batchSaving" @click="handleBatchCreate">批量制定</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -445,15 +376,4 @@ const statusLabel: Record<string, string> = { green: '本月已拜访', yellow:
.dlg-plan-item.dlg-plan-overdue { background: #FBF1EE; margin: 2px -4px; padding: 4px; border-radius: 4px; }
.dlg-plan-form { background: var(--c-bg-light, #faf9f6); padding: 10px 12px; border-radius: 6px; }
.plan-form-row { display: flex; gap: 8px; align-items: center; }
/* ═══ Card Checkbox ═══ */
.card-check {
position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 14px; color: var(--warm-gray);
border-radius: 4px; background: rgba(255,255,255,0.8);
z-index: 2;
}
.card-check:hover { color: var(--ink); background: rgba(196,147,74,0.1); }
.customer-card.card-selected { border-color: var(--gold); box-shadow: 0 0 0 2px rgba(196,147,74,0.2); }
</style>
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { todayStr } from '@/utils'
import api from '@/api/index'
import { compressImage } from '@/utils/image'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
@@ -92,7 +93,7 @@ async function loadAll() {
function openCreate(type: string) {
dialogMode.value = 'create'; dialogType.value = type
dialogTimeRange.value = null
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '' }
if (type === 'visit') form.value = { customer_id: '', visit_date: todayStr(), visit_method: '上门', time_range: '', visitor_name: '', visitor_phone: '', communication_content: '', customer_demand: '', companions: [], companion_names: [] }
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '' }
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '' }
@@ -134,11 +135,13 @@ async function handleDialogPhotoUpload(event: Event) {
for (const file of Array.from(target.files)) {
if ((form.value.photos || []).length >= 9) break
try {
// Compress before upload to reduce storage & transfer
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
// Get presigned URL
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: file.name, content_type: file.type || 'image/jpeg' } })
const presignRes = await api.post('/upload/presigned-url', null, { params: { filename: compressed.name, content_type: compressed.type || 'image/jpeg' } })
// Upload directly to MinIO (not through our API)
const axios = (await import('axios')).default
await axios.put(presignRes.data.upload_url, file, { headers: { 'Content-Type': file.type || 'image/jpeg' } })
await axios.put(presignRes.data.upload_url, compressed, { headers: { 'Content-Type': compressed.type || 'image/jpeg' } })
const key = presignRes.data.object_key
if (!form.value.photos) form.value.photos = []
form.value.photos.push(key)
@@ -167,8 +170,23 @@ function removePhotoFromEdit(idx: number) {
}
}
function splitCompanions(values: string[]) {
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const sys: string[] = []; const ext: string[] = []
for (const v of (values || [])) {
if (uuidRe.test(v)) sys.push(v)
else if (v.trim()) ext.push(v.trim())
}
return { companions: sys, companion_names: ext }
}
async function handleSave() {
const t = dialogType.value; const d = form.value
const t = dialogType.value; let d = { ...form.value }
// Split companions for visit type
if (t === 'visit' && d.companions) {
const { companions, companion_names } = splitCompanions(d.companions)
d = { ...d, companions, companion_names }
}
try {
if (dialogMode.value === 'create') {
switch (t) {
@@ -443,6 +461,11 @@ const notesByDate = computed(() => {
</el-form-item>
<el-form-item label="拜访人姓名"><el-input v-model="form.visitor_name" placeholder="实际拜访人姓名" /></el-form-item>
<el-form-item label="拜访人电话"><el-input v-model="form.visitor_phone" placeholder="联系电话可选" /></el-form-item>
<el-form-item label="同访人员">
<el-select v-model="form.companions" multiple filterable allow-create default-first-option placeholder="选择或输入姓名可多选" style="width:100%">
<el-option v-for="u in allUsers" :key="u.id" :label="u.name" :value="u.id" />
</el-select>
</el-form-item>
<el-form-item label="沟通内容"><el-input v-model="form.communication_content" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="客户需求"><el-input v-model="form.customer_demand" type="textarea" :rows="2" /></el-form-item>
<el-form-item v-if="dialogMode === 'edit' && form.photos?.length" label="照片">
+14 -1
View File
@@ -178,7 +178,20 @@ async function handleImportPreview() {
</div>
<div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false">
导入完成拜访 {{ importResult.visits }} 跳过 {{ importResult.skipped }}
导入完成拜访 {{ importResult.visits || 0 }} 纪要 {{ importResult.daily_notes || 0 }} 计划 {{ importResult.work_plans || 0 }} 商机 {{ importResult.mini_business || 0 }} 要客 {{ importResult.key_visits || 0 }}
<template v-if="importResult.customers_created">自动创建客户 {{ importResult.customers_created }} </template>
<template v-if="importResult.external_companions">外部同访人 {{ importResult.external_companions }} </template>
<template v-if="importResult.skipped">跳过 {{ importResult.skipped }} </template>
<template v-if="importResult.name_corrections?.length">
<div style="margin-top:6px; font-size:12px; color:var(--amber)">
<div v-for="(r, i) in importResult.name_corrections.slice(0, 10)" :key="'nc'+i">🔧 {{ r }}</div>
</div>
</template>
<template v-if="importResult.customers_created_names?.length">
<div style="margin-top:4px; font-size:12px; color:var(--sage)">
🆕 新建客户{{ importResult.customers_created_names.join('、') }}
</div>
</template>
<template v-if="importResult.skip_reasons?.length">
<div style="margin-top:8px; font-size:12px; max-height:200px; overflow-y:auto">
<div v-for="(r, i) in importResult.skip_reasons.slice(0, 20)" :key="i"> {{ r }}</div>
+48 -32
View File
@@ -38,29 +38,40 @@ const photoUrls = ref<Record<string, string>>({})
const photoDialogVisible = ref(false)
const currentPhotoUrl = ref('')
onMounted(async () => {
onMounted(() => {
if (route.query.manager_id) filterManagerId.value = route.query.manager_id as string
if (route.query.customer_id) filterCustomerId.value = route.query.customer_id as string
await loadReport()
try {
const [mRes, cRes] = await Promise.all([
api.get('/users/', { params: { role: 'manager' } }),
customersApi.list({ page_size: 500 }),
])
managers.value = mRes.data
customers.value = cRes.data.items || cRes.data
} catch (_) {}
// Auto-load cached AI summary
if (auth.isDirector || auth.isLeader) {
// Kick off report load immediately (includes photo URL fetching)
const reportPromise = loadReport()
// Dropdown data loads in parallel with report
const dropdownsPromise = (async () => {
try {
const cached = await aiApi.getSummary({ reference_date: getRefDate(), period: 'week' })
if (cached.data?.summary) {
aiSummary.value = cached.data.summary
aiCached.value = !!cached.data.cached
aiCreatedAt.value = cached.data.created_at || ''
}
const [mRes, cRes] = await Promise.all([
api.get('/users/', { params: { role: 'manager' } }),
customersApi.list({ page_size: 500 }),
])
managers.value = mRes.data
customers.value = cRes.data.items || cRes.data
} catch (_) {}
}
})()
// AI summary loads in parallel too
const aiPromise = (async () => {
if (auth.isDirector || auth.isLeader) {
try {
const cached = await aiApi.getSummary({ reference_date: getRefDate(), period: 'week' })
if (cached.data?.summary) {
aiSummary.value = cached.data.summary
aiCached.value = !!cached.data.cached
aiCreatedAt.value = cached.data.created_at || ''
}
} catch (_) {}
}
})()
Promise.all([reportPromise, dropdownsPromise, aiPromise])
})
function changeWeek(delta: number) { weekOffset.value += delta; loadReport() }
@@ -81,17 +92,19 @@ async function loadReport() {
params.reference_date = getRefDate()
const res = await dashboardApi.getWeeklyReport(params)
report.value = res.data
// Collect all unique photo keys first, then fetch in parallel
const photoKeys = new Set<string>()
for (const v of report.value.visits) {
if (v.photos?.length) {
for (const key of v.photos) {
if (!photoUrls.value[key]) {
try {
const urlRes = await uploadApi.getDownloadUrl(key)
photoUrls.value[key] = urlRes.data.download_url
} catch (_) {}
}
}
}
for (const key of (v.photos || [])) photoKeys.add(key)
}
const newKeys = [...photoKeys].filter(k => !photoUrls.value[k])
if (newKeys.length > 0) {
const results = await Promise.allSettled(
newKeys.map(k => uploadApi.getDownloadUrl(k))
)
results.forEach((r, i) => {
if (r.status === 'fulfilled') photoUrls.value[newKeys[i]] = r.value.data.download_url
})
}
} catch (e: any) {
ElMessage.error('加载周报失败')
@@ -316,9 +329,12 @@ const notesByDate = computed(() => {
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="customer_demand" label="客户需求" min-width="150" show-overflow-tooltip />
<el-table-column label="客户经理" width="100">
<el-table-column label="相关人员" width="120">
<template #default="{ row }">
<span>{{ row.manager_name }}</span>
<div style="display:flex;flex-wrap:wrap;gap:2px">
<span>{{ row.manager_name }}</span>
<span v-for="n in (row.companion_names_resolved || [])" :key="n" style="color:var(--warm-gray);font-size:12px">, {{ n }}</span>
</div>
<el-tooltip v-if="row.edit_log?.length > 1" placement="top">
<template #content>最后编辑{{ row.edit_log[row.edit_log.length-1].editor }} · {{ row.edit_log.length-1 }}次修改</template>
<span class="edit-indicator" title="有过修改">🕐</span>
@@ -359,7 +375,7 @@ const notesByDate = computed(() => {
</el-table-column>
<el-table-column prop="content" label="工作内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="time_range" label="时间" width="100" />
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column prop="manager_name" label="填报人" width="80" />
</el-table>
</div>
</el-tab-pane>
+22 -6
View File
@@ -6,6 +6,7 @@ import { todayStr } from '@/utils'
import { visitsApi } from '@/api/visits'
import { customersApi } from '@/api/customers'
import { uploadApi } from '@/api/upload'
import { compressImage } from '@/utils/image'
import { useAuthStore } from '@/stores/auth'
import ImagePreview from '@/components/ImagePreview.vue'
import EditLogPanel from '@/components/EditLogPanel.vue'
@@ -121,8 +122,10 @@ async function handlePhotoUpload(event: Event) {
const previewUrl = URL.createObjectURL(file)
photoPreviews.value.push(previewUrl)
try {
const res = await uploadApi.getPresignedUrl(file.name, file.type || 'image/jpeg')
await uploadApi.uploadFile(res.data.upload_url, file)
// Compress before upload to reduce storage & transfer
const compressed = await compressImage(file, { maxPixels: 1920, quality: 0.8 })
const res = await uploadApi.getPresignedUrl(compressed.name, compressed.type || 'image/jpeg')
await uploadApi.uploadFile(res.data.upload_url, compressed)
uploadedPhotos.value.push(res.data.object_key)
form.value.photos = [...uploadedPhotos.value]
} catch (e: any) {
@@ -154,15 +157,28 @@ function removePhoto(index: number) {
form.value.photos = [...uploadedPhotos.value]
}
function splitCompanions(values: string[]) {
// UUIDs → companions (system users), non-UUID strings → companion_names (external)
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const sys: string[] = []; const ext: string[] = []
for (const v of values) {
if (uuidRe.test(v)) sys.push(v)
else if (v.trim()) ext.push(v.trim())
}
return { companions: sys, companion_names: ext }
}
async function handleSubmit() {
if (!form.value.customer_id) { ElMessage.warning('请选择客户'); return }
submitLoading.value = true
try {
const { companions, companion_names } = splitCompanions(form.value.companions || [])
const body = { ...form.value, companions, companion_names }
if (isEdit.value) {
await visitsApi.update(route.params.id as string, form.value)
await visitsApi.update(route.params.id as string, body)
ElMessage.success('记录已更新')
} else {
await visitsApi.create(form.value)
await visitsApi.create(body)
ElMessage.success('拜访记录已提交')
}
router.push('/m')
@@ -269,9 +285,9 @@ async function handleDelete() {
<el-form-item>
<template #label>
<span class="form-label">同访人员</span>
<span class="form-label">同访人员 <span class="form-label-hint">可输入外部人员</span></span>
</template>
<el-select v-model="form.companions" multiple filterable placeholder="选择同访人员" style="width:100%">
<el-select v-model="form.companions" multiple filterable allow-create default-first-option placeholder="选择或输入姓名(可多选)" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" :disabled="m.id === auth.userId" />
</el-select>
</el-form-item>