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:
@@ -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>
|
||||
|
||||
Vendored
+52
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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="照片">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user