ab20bc5a1f
# Conflicts: # backend/app/api/key_visits.py # backend/app/api/mini_business.py # backend/app/api/visits.py # backend/app/api/work_plans.py # backend/app/main.py # backend/app/models/__init__.py # backend/app/schemas/key_visit.py # backend/app/schemas/mini_business.py # backend/app/schemas/work_plan.py # backend/app/services/light_board.py # frontend/src/components/DesktopLayout.vue # frontend/src/stores/theme.ts # frontend/src/views/desktop/ManagerWorkspace.vue # frontend/src/views/desktop/WorkPlans.vue # frontend/src/views/mobile/KeyVisitForm.vue # frontend/src/views/mobile/LeaveForm.vue # frontend/src/views/mobile/PlansList.vue # frontend/src/views/mobile/VisitForm.vue # frontend/src/views/mobile/WorkPlanForm.vue
689 lines
38 KiB
Vue
689 lines
38 KiB
Vue
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { todayStr } from '@/utils'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { getMgrStyle } from '@/utils/managerColor'
|
||
import api from '@/api/index'
|
||
import { compressImage } from '@/utils/image'
|
||
import ImagePreview from '@/components/ImagePreview.vue'
|
||
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||
|
||
const auth = useAuthStore()
|
||
|
||
const activeTab = ref('visits')
|
||
const loading = ref(false)
|
||
const allManagers = ref<any[]>([])
|
||
|
||
// Date range filter
|
||
const dateRange = ref<any>(null)
|
||
const searchText = ref('')
|
||
const pageSize = ref(25)
|
||
|
||
const visits = ref<any[]>([])
|
||
const workPlans = ref<any[]>([])
|
||
const miniBusiness = ref<any[]>([])
|
||
const dailyNotes = ref<any[]>([])
|
||
const keyVisits = ref<any[]>([])
|
||
|
||
// Pagination per tab
|
||
const page = ref<Record<string, number>>({ visits: 1, notes: 1, plans: 1, mini: 1, key: 1 })
|
||
const total = ref<Record<string, number>>({ visits: 0, notes: 0, plans: 0, mini: 0, key: 0 })
|
||
|
||
function getDateRange() {
|
||
if (dateRange.value && Array.isArray(dateRange.value) && dateRange.value.length === 2) {
|
||
return { from: dateRange.value[0], to: dateRange.value[1] }
|
||
}
|
||
return { from: '', to: '' }
|
||
}
|
||
const customers = ref<any[]>([])
|
||
const allUsers = ref<any[]>([])
|
||
const plannedVisitors = ref<string[]>([])
|
||
const dialogTimeRange = ref<any>(null)
|
||
const previewImageUrl = ref('')
|
||
const uploading = ref(false)
|
||
const previewDialogVisible = ref(false)
|
||
const photoUrls = ref<Record<string, string>>({})
|
||
|
||
const dialogVisible = ref(false)
|
||
const dialogMode = ref<'create' | 'edit'>('create')
|
||
const dialogType = ref('')
|
||
const form = ref<any>({})
|
||
|
||
const categories = ['行政事务', '合同整理', '发票处理', '内部会议', '培训学习', '其他']
|
||
|
||
const statusPick = ref<Record<string, string>>({})
|
||
|
||
const planStatuses = ['计划中', '已完成', '已取消']
|
||
const miniStatuses = ['跟进中', '已签约', '已流失']
|
||
const keyStatuses = ['未开始', '进行中', '已完成']
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadAll(), loadCustomers(), loadUsers()])
|
||
if (auth.isDirector || auth.isLeader) {
|
||
api.get('/users/', { params: { role: 'manager' } }).then(r => { allManagers.value = r.data }).catch(() => {})
|
||
}
|
||
})
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
const res = await api.get('/users/')
|
||
allUsers.value = res.data || []
|
||
} catch (_) {}
|
||
}
|
||
|
||
function typeLabel(t: string) {
|
||
const labels: Record<string, string> = { visit: '拜访记录', note: '今日纪要', plan: '工作计划', mini: '小微商机', key: '要客拜访' }
|
||
return labels[t] || ''
|
||
}
|
||
|
||
const customerSearch = ref('')
|
||
|
||
async function loadCustomers(q?: string) {
|
||
try {
|
||
const params: any = { page_size: 100 }
|
||
if (q) params.search = q
|
||
const res = await api.get('/customers/', { params })
|
||
customers.value = res.data.items || []
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function handleCustomerSearch(query: string) {
|
||
customerSearch.value = query
|
||
if (query) { await loadCustomers(query) }
|
||
else { await loadCustomers() }
|
||
}
|
||
|
||
async function handleQuickCreate() {
|
||
const name = customerSearch.value.trim()
|
||
if (!name) { ElMessage.warning('请输入单位名称'); return }
|
||
try {
|
||
const res = await api.post('/customers/quick-create', null, { params: { name } })
|
||
const newCust = res.data
|
||
customers.value.unshift(newCust)
|
||
form.value.customer_id = newCust.id
|
||
customerSearch.value = ''
|
||
ElMessage.success(`已创建客户:${name}`)
|
||
await loadCustomers()
|
||
} catch (e: any) {
|
||
ElMessage.error('创建失败: ' + (e.response?.data?.detail || e.message))
|
||
}
|
||
}
|
||
|
||
function buildParams(tabKey: string) {
|
||
const { from, to } = getDateRange()
|
||
const params: any = { page: page.value[tabKey] || 1, page_size: pageSize.value }
|
||
if (from) { params.date_from = from; params.date_to = to }
|
||
if (searchText.value) params.search = searchText.value
|
||
return params
|
||
}
|
||
|
||
async function loadAll() {
|
||
loading.value = true
|
||
try {
|
||
const [v, d] = await Promise.all([
|
||
api.get('/visits/', { params: buildParams('visits') }),
|
||
api.get('/daily-notes/', { params: buildParams('notes') }),
|
||
])
|
||
visits.value = Array.isArray(v.data) ? v.data : (v.data.items || [])
|
||
dailyNotes.value = Array.isArray(d.data) ? d.data : (d.data.items || [])
|
||
total.value.visits = v.data.total || visits.value.length
|
||
total.value.notes = d.data.total || dailyNotes.value.length
|
||
|
||
const [w, m, k] = await Promise.all([
|
||
api.get('/work-plans/', { params: buildParams('plans') }),
|
||
api.get('/mini-business/', { params: buildParams('mini') }),
|
||
api.get('/key-visits/', { params: buildParams('key') }),
|
||
])
|
||
workPlans.value = Array.isArray(w.data) ? w.data : (w.data.items || [])
|
||
miniBusiness.value = Array.isArray(m.data) ? m.data : (m.data.items || [])
|
||
keyVisits.value = Array.isArray(k.data) ? k.data : (k.data.items || [])
|
||
total.value.plans = w.data.total || workPlans.value.length
|
||
total.value.mini = m.data.total || miniBusiness.value.length
|
||
total.value.key = k.data.total || keyVisits.value.length
|
||
|
||
// Load photo previews for visits
|
||
for (const visit of visits.value) {
|
||
if (visit.photos?.length) {
|
||
for (const key of visit.photos) {
|
||
if (!photoUrls.value[key]) {
|
||
try {
|
||
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
|
||
photoUrls.value[key] = urlRes.data.download_url
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e: any) { ElMessage.error('加载失败') }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
function onTabChange(tab: string) { activeTab.value = tab; loadAll() }
|
||
function onFilterChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); loadAll() }
|
||
function onPageChange(tabKey: string, p: number) { page.value[tabKey] = p; loadAll() }
|
||
function onSizeChange() { Object.keys(page.value).forEach(k => page.value[k] = 1); 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: '', companions: [], companion_names: [], manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||
else if (type === 'note') form.value = { note_date: todayStr(), category: '其他', content: '', time_range: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||
else if (type === 'plan') form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||
else if (type === 'mini') form.value = { customer_id: '', product_type: '', amount: '', follow_up_detail: '', status: '跟进中', expected_revenue_date: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }
|
||
else if (type === 'key') { form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '', manager_id: (auth.isDirector || auth.isLeader) ? '' : (auth.userId || '') }; plannedVisitors.value = [] }
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function openEdit(type: string, item: any) {
|
||
dialogMode.value = 'edit'; dialogType.value = type
|
||
form.value = { ...item }
|
||
if (item.customer_id && item.customer_name && !customers.value.find((c: any) => c.id === item.customer_id)) {
|
||
customers.value.unshift({ id: item.customer_id, name: item.customer_name })
|
||
}
|
||
// Ensure assigned manager is in the select options (avoid showing raw UUID)
|
||
if (item.manager_id && item.manager_name && !allUsers.value.find((u: any) => u.id === item.manager_id)) {
|
||
allUsers.value.unshift({ id: item.manager_id, name: item.manager_name })
|
||
}
|
||
dialogTimeRange.value = null
|
||
if ((type === 'visit' || type === 'note') && item.time_range && item.time_range.includes('-')) {
|
||
const parts = item.time_range.split('-')
|
||
dialogTimeRange.value = [parts[0], parts[1]]
|
||
}
|
||
if (type === 'key') {
|
||
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
|
||
}
|
||
// Load photo URLs for visit editing
|
||
if (type === 'visit' && item.photos?.length) {
|
||
for (const key of item.photos) {
|
||
if (!photoUrls.value[key]) {
|
||
api.get('/upload/download-url', { params: { object_key: key } }).then(r => {
|
||
photoUrls.value[key] = r.data.download_url
|
||
}).catch(() => {})
|
||
}
|
||
}
|
||
}
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function previewPhoto(url: string) { previewImageUrl.value = url; previewDialogVisible.value = true }
|
||
|
||
async function handleDialogPhotoUpload(event: Event) {
|
||
const target = event.target as HTMLInputElement
|
||
if (!target.files?.length) return
|
||
if ((form.value.photos || []).length >= 9) { ElMessage.warning('最多9张照片'); return }
|
||
uploading.value = true
|
||
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 })
|
||
const data = new FormData()
|
||
data.append('file', compressed)
|
||
const uploadRes = await api.post('/upload/image', data, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
timeout: 60000,
|
||
})
|
||
const key = uploadRes.data.object_key
|
||
if (!form.value.photos) form.value.photos = []
|
||
form.value.photos.push(key)
|
||
form.value.photos = [...form.value.photos]
|
||
// Load preview
|
||
try {
|
||
const urlRes = await api.get('/upload/download-url', { params: { object_key: key } })
|
||
photoUrls.value[key] = urlRes.data.download_url
|
||
} catch (_) {}
|
||
} catch (e: any) {
|
||
ElMessage.error('上传失败: ' + (e.response?.data?.detail || e.message))
|
||
}
|
||
}
|
||
uploading.value = false
|
||
target.value = ''
|
||
}
|
||
|
||
function removePhotoFromEdit(idx: number) {
|
||
if (!form.value.photos) return
|
||
const key = form.value.photos[idx]
|
||
form.value.photos.splice(idx, 1)
|
||
form.value.photos = [...form.value.photos]
|
||
// Clean up preview URL
|
||
if (photoUrls.value[key]) {
|
||
delete photoUrls.value[key]
|
||
}
|
||
}
|
||
|
||
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; 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) {
|
||
case 'visit': await api.post('/visits/', d); break
|
||
case 'note': await api.post('/daily-notes/', d); break
|
||
case 'plan': await api.post('/work-plans/', d); break
|
||
case 'mini': await api.post('/mini-business/', d); break
|
||
case 'key': await api.post('/key-visits/', d); break
|
||
}
|
||
} else {
|
||
switch (t) {
|
||
case 'visit': await api.put(`/visits/${d.id}`, d); break
|
||
case 'note': await api.put(`/daily-notes/${d.id}`, d); break
|
||
case 'plan': await api.put(`/work-plans/${d.id}`, d); break
|
||
case 'mini': await api.put(`/mini-business/${d.id}`, d); break
|
||
case 'key': await api.put(`/key-visits/${d.id}`, d); break
|
||
}
|
||
}
|
||
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
|
||
dialogVisible.value = false
|
||
await loadAll()
|
||
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
|
||
}
|
||
|
||
async function handleDelete(type: string, id: string) {
|
||
try {
|
||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||
switch (type) {
|
||
case 'visit': await api.delete(`/visits/${id}`); break
|
||
case 'note': await api.delete(`/daily-notes/${id}`); break
|
||
case 'plan': await api.delete(`/work-plans/${id}`); break
|
||
case 'mini': await api.delete(`/mini-business/${id}`); break
|
||
case 'key': await api.delete(`/key-visits/${id}`); break
|
||
}
|
||
ElMessage.success('已删除')
|
||
await loadAll()
|
||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||
}
|
||
|
||
function onVisitorsChange(val: string[]) {
|
||
form.value.planned_visitor = val.join('、')
|
||
}
|
||
|
||
function onDialogTimeChange(val: [string, string] | null) {
|
||
form.value.time_range = val ? val.join('-') : ''
|
||
}
|
||
|
||
async function onDialogCustomerChange(customerId: string) {
|
||
// Only pre-fill for new visit creation (not edit)
|
||
if (dialogMode.value !== 'create' || dialogType.value !== 'visit' || !customerId) return
|
||
try {
|
||
const planRes = await api.get(`/customers/${customerId}/overdue-plans`)
|
||
if (planRes.data?.plan?.plan_content) {
|
||
form.value.communication_content = planRes.data.plan.plan_content
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
|
||
// Quick status change
|
||
async function quickStatusChange(type: string, row: any, newStatus: string) {
|
||
try {
|
||
await ElMessageBox.confirm(`确定将状态改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
|
||
const payload: any = {}
|
||
if (type === 'plan') payload.status = newStatus
|
||
else if (type === 'mini') payload.status = newStatus
|
||
else if (type === 'key') payload.progress_status = newStatus
|
||
let url = ''
|
||
if (type === 'plan') url = `/work-plans/${row.id}`
|
||
else if (type === 'mini') url = `/mini-business/${row.id}`
|
||
else if (type === 'key') url = `/key-visits/${row.id}`
|
||
await api.put(url, payload)
|
||
ElMessage.success('状态已更新')
|
||
await loadAll()
|
||
} catch (e: any) {
|
||
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
|
||
// Reset statusPick back to original on cancel/error
|
||
delete statusPick.value[row.id]
|
||
}
|
||
}
|
||
|
||
const visitsByDate = computed(() => {
|
||
const g: Record<string, any[]> = {}
|
||
for (const v of visits.value) { const d = v.visit_date; if (!g[d]) g[d] = []; g[d].push(v) }
|
||
return Object.entries(g).sort((a, b) => b[0].localeCompare(a[0]))
|
||
})
|
||
const notesByDate = computed(() => {
|
||
const g: Record<string, any[]> = {}
|
||
for (const n of dailyNotes.value) { const d = n.note_date; if (!g[d]) g[d] = []; g[d].push(n) }
|
||
return Object.entries(g).sort((a, b) => b[0].localeCompare(a[0]))
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="workspace" v-loading="loading">
|
||
<div class="page-head">
|
||
<div class="page-head-row">
|
||
<h2 class="page-title">我的工作数据</h2>
|
||
<div class="page-head-controls">
|
||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" style="width:240px" value-format="YYYY-MM-DD" @change="onFilterChange" />
|
||
<el-button v-if="dateRange" size="small" @click="dateRange=null;onFilterChange()">清除</el-button>
|
||
<el-input v-model="searchText" placeholder="搜索..." clearable size="small" style="width:240px" @keyup.enter="onFilterChange" @clear="onFilterChange">
|
||
<template #append><el-button size="small" @click="onFilterChange">搜索</el-button></template>
|
||
</el-input>
|
||
</div>
|
||
</div>
|
||
<div class="page-rule"></div>
|
||
</div>
|
||
|
||
<el-card>
|
||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||
<!-- ═══ 拜访记录 ═══ -->
|
||
<el-tab-pane label="拜访记录" name="visits">
|
||
<div v-for="[date, items] in visitsByDate" :key="date" class="date-group">
|
||
<h4 class="date-title">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
|
||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
|
||
</svg>
|
||
{{ date }}
|
||
</h4>
|
||
<el-table :data="items" stripe size="small" v-column-resize>
|
||
<el-table-column prop="customer_name" label="客户" width="130">
|
||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('visit', row)">{{ row.customer_name }}</el-link></template>
|
||
</el-table-column>
|
||
<el-table-column v-if="auth.isDirector || auth.isLeader" label="客户经理" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :style="getMgrStyle(row.manager_name || '', false)" effect="dark" size="small" disable-transitions>{{ row.manager_name || '-' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="visit_method" label="方式" width="60" />
|
||
<el-table-column prop="time_range" label="时间" width="100" />
|
||
<el-table-column label="照片" width="60">
|
||
<template #default="{ row }">
|
||
<span v-if="row.photos?.length">📷{{ row.photos.length }}</span>
|
||
<span v-else style="color:var(--c-text-muted)">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
|
||
<el-table-column label="操作" width="70">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="danger" @click="handleDelete('visit', row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<div v-if="!visits.length" class="empty">暂无数据</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ═══ 今日纪要 ═══ -->
|
||
<el-tab-pane label="今日纪要" name="daily_notes">
|
||
<div v-for="[date, items] in notesByDate" :key="date" class="date-group">
|
||
<h4 class="date-title">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; color: var(--gold)">
|
||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line>
|
||
</svg>
|
||
{{ date }}
|
||
</h4>
|
||
<el-table :data="items" stripe size="small" v-column-resize>
|
||
<el-table-column prop="category" label="分类" width="100"><template #default="{ row }"><el-tag size="small">{{ row.category }}</el-tag></template></el-table-column>
|
||
<el-table-column v-if="auth.isDirector || auth.isLeader" label="客户经理" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :style="getMgrStyle(row.manager_name || '', false)" effect="dark" size="small" disable-transitions>{{ row.manager_name || '-' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="content" label="工作内容" min-width="250" show-overflow-tooltip>
|
||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('note', row)">{{ row.content }}</el-link></template>
|
||
</el-table-column>
|
||
<el-table-column prop="time_range" label="时间" width="100" />
|
||
<el-table-column label="操作" width="70">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="danger" @click="handleDelete('note', row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<div v-if="!dailyNotes.length" class="empty">暂无数据</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ═══ 工作计划 ═══ -->
|
||
<el-tab-pane label="工作计划" name="work_plans">
|
||
<el-table :data="workPlans" stripe size="small" v-column-resize>
|
||
<el-table-column prop="customer_name" label="客户" width="130">
|
||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('plan', row)">{{ row.customer_name }}</el-link></template>
|
||
</el-table-column>
|
||
<el-table-column prop="plan_content" label="工作计划" min-width="200" show-overflow-tooltip />
|
||
<el-table-column prop="plan_date" label="计划时间" width="110" />
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-select
|
||
v-model="statusPick[row.id]"
|
||
size="small"
|
||
style="width:100%"
|
||
:placeholder="row.status"
|
||
@change="(v: string) => quickStatusChange('plan', row, v)"
|
||
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
|
||
>
|
||
<el-option v-for="s in planStatuses" :key="s" :label="s" :value="s" />
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="70">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="danger" @click="handleDelete('plan', row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div v-if="!workPlans.length" class="empty">暂无数据</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ═══ 小微商机 ═══ -->
|
||
<el-tab-pane label="小微商机" name="mini_biz">
|
||
<el-table :data="miniBusiness" stripe size="small" v-column-resize>
|
||
<el-table-column prop="customer_name" label="客户" width="130">
|
||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('mini', row)">{{ row.customer_name }}</el-link></template>
|
||
</el-table-column>
|
||
<el-table-column prop="product_type" label="产品类型" width="120" />
|
||
<el-table-column prop="amount" label="金额" width="90" />
|
||
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-select
|
||
v-model="statusPick[row.id]"
|
||
size="small"
|
||
style="width:100%"
|
||
:placeholder="row.status"
|
||
@change="(v: string) => quickStatusChange('mini', row, v)"
|
||
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.status }"
|
||
>
|
||
<el-option v-for="s in miniStatuses" :key="s" :label="s" :value="s" />
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="expected_revenue_date" label="预计列收" width="110" />
|
||
<el-table-column label="操作" width="70">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="danger" @click="handleDelete('mini', row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div v-if="!miniBusiness.length" class="empty">暂无数据</div>
|
||
</el-tab-pane>
|
||
|
||
<!-- ═══ 要客拜访 ═══ -->
|
||
<el-tab-pane label="要客拜访" name="key_visits">
|
||
<el-table :data="keyVisits" stripe size="small" v-column-resize>
|
||
<el-table-column prop="customer_name" label="客户" width="130">
|
||
<template #default="{ row }"><el-link type="primary" :underline="false" @click="openEdit('key', row)">{{ row.customer_name }}</el-link></template>
|
||
</el-table-column>
|
||
<el-table-column prop="urgency_level" label="重要度" width="80"><template #default="{ row }"><el-tag size="small" :type="row.urgency_level==='紧急'?'danger':row.urgency_level==='重要'?'warning':''">{{ row.urgency_level }}</el-tag></template></el-table-column>
|
||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||
<el-table-column label="进展" width="120">
|
||
<template #default="{ row }">
|
||
<el-select
|
||
v-model="statusPick[row.id]"
|
||
size="small"
|
||
style="width:100%"
|
||
:placeholder="row.progress_status"
|
||
@change="(v: string) => quickStatusChange('key', row, v)"
|
||
@visible-change="(v: boolean) => { if (v) statusPick[row.id] = row.progress_status }"
|
||
>
|
||
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
|
||
</el-select>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="planned_date" label="计划时间" width="110" />
|
||
<el-table-column prop="planned_visitor" label="拜访人" width="80" />
|
||
<el-table-column prop="visit_target" label="拜访对象" width="100" />
|
||
<el-table-column label="操作" width="70">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="danger" @click="handleDelete('key', row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div v-if="!keyVisits.length" class="empty">暂无数据</div>
|
||
</el-tab-pane>
|
||
<div style="margin-top:16px;display:flex;align-items:center;gap:12px">
|
||
<el-pagination
|
||
v-model:current-page="page[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
|
||
:page-size="pageSize"
|
||
:total="total[activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits']"
|
||
:page-sizes="[25, 50, 100]"
|
||
layout="total, sizes, prev, pager, next"
|
||
small
|
||
@current-change="(p: number) => onPageChange(activeTab === 'notes' ? 'notes' : activeTab === 'plans' ? 'plans' : activeTab === 'mini' ? 'mini' : activeTab === 'key' ? 'key' : 'visits', p)"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</div>
|
||
</el-tabs>
|
||
</el-card>
|
||
|
||
<!-- Create/Edit Dialog -->
|
||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' ' + typeLabel(dialogType)" width="520px">
|
||
<el-form label-position="top" v-if="form">
|
||
<el-form-item v-if="['visit','plan','mini','key'].includes(dialogType)" label="客户单位 *">
|
||
<el-select v-model="form.customer_id" filterable remote :remote-method="handleCustomerSearch" placeholder="搜索选择客户" style="width:100%" @change="onDialogCustomerChange">
|
||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||
<template #empty>
|
||
<div v-if="customerSearch" class="select-empty-create">
|
||
<p style="color:var(--warm-gray);font-size:13px;margin:0 0 8px">未找到「{{ customerSearch }}」</p>
|
||
<button type="button" class="quick-create-btn" @click.stop="handleQuickCreate">+ 新建客户「{{ customerSearch }}」</button>
|
||
</div>
|
||
</template>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="(auth.isDirector || auth.isLeader) && ['visit','plan','mini','key','note'].includes(dialogType)" label="客户经理">
|
||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||
<el-option v-for="m in allManagers" :key="m.id" :label="m.name" :value="m.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="dialogType === 'visit'" label="日期"><el-date-picker v-model="form.visit_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||
<el-form-item v-if="dialogType === 'note'" label="日期"><el-date-picker v-model="form.note_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||
<el-form-item v-if="dialogType === 'plan'" label="计划拜访时间"><el-date-picker v-model="form.plan_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
|
||
<el-form-item v-if="dialogType === 'visit' && (auth.isDirector || auth.isLeader)" label="客户经理">
|
||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||
<el-option v-for="u in allUsers.filter((u: any) => u.role === 'manager')" :key="u.id" :label="u.name" :value="u.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item v-if="(dialogType === 'plan' || dialogType === 'mini' || dialogType === 'key') && (auth.isDirector || auth.isLeader)" label="客户经理">
|
||
<el-select v-model="form.manager_id" filterable placeholder="选择客户经理" style="width:100%">
|
||
<el-option v-for="u in allUsers.filter((u: any) => u.role === 'manager')" :key="u.id" :label="u.name" :value="u.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<template v-if="dialogType === 'visit'">
|
||
<el-form-item label="拜访方式">
|
||
<div class="method-grid">
|
||
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="form.visit_method===m?'primary':''" @click="form.visit_method=m">{{ m }}</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="时间范围">
|
||
<el-time-picker v-model="dialogTimeRange" is-range format="HH:mm" value-format="HH:mm" range-separator="至" start-placeholder="开始" end-placeholder="结束" style="width:100%" @change="onDialogTimeChange" />
|
||
</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="照片">
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<div v-for="(key, idx) in form.photos" :key="key" style="position:relative">
|
||
<img :src="photoUrls[key]" style="width:80px;height:80px;object-fit:cover;border:1px solid var(--warm-border);cursor:pointer" v-if="photoUrls[key]" @click.stop="previewPhoto(photoUrls[key])" />
|
||
<span v-else style="width:80px;height:80px;display:flex;align-items:center;justify-content:center;background:var(--paper);border:1px solid var(--warm-border);font-size:11px;color:var(--warm-gray)">加载中...</span>
|
||
<button type="button" style="position:absolute;top:-6px;right:-6px;background:var(--vermilion);color:#fff;border:none;width:18px;height:18px;font-size:12px;cursor:pointer;line-height:1;border-radius:50%" @click="removePhotoFromEdit(idx)" title="删除照片">×</button>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:4px;font-size:11px;color:var(--warm-gray)">点击 × 移除照片,点击「保存」提交更改</div>
|
||
</el-form-item>
|
||
<el-form-item v-if="dialogType === 'visit'" label="添加照片">
|
||
<label style="cursor:pointer;display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:1px dashed var(--warm-border);color:var(--warm-gray);font-size:13px">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||
{{ uploading ? '上传中...' : '上传照片(最多9张)' }}
|
||
<input type="file" accept="image/*" multiple style="display:none" @change="handleDialogPhotoUpload" />
|
||
</label>
|
||
</el-form-item>
|
||
</template>
|
||
<template v-if="dialogType === 'note'">
|
||
<el-form-item label="分类"><div style="display:flex;flex-wrap:wrap;gap:6px"><el-button v-for="c in categories" :key="c" :type="form.category===c?'primary':''" size="small" @click="form.category=c">{{ c }}</el-button></div></el-form-item>
|
||
<el-form-item label="时间范围">
|
||
<el-time-picker v-model="dialogTimeRange" is-range format="HH:mm" value-format="HH:mm" range-separator="至" start-placeholder="开始" end-placeholder="结束" style="width:100%" @change="onDialogTimeChange" />
|
||
</el-form-item>
|
||
<el-form-item label="工作内容"><el-input v-model="form.content" type="textarea" :rows="4" /></el-form-item>
|
||
</template>
|
||
<template v-if="dialogType === 'plan'">
|
||
<el-form-item label="工作计划"><el-input v-model="form.plan_content" type="textarea" :rows="4" /></el-form-item>
|
||
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="计划中" value="计划中" /><el-option label="已完成" value="已完成" /><el-option label="已取消" value="已取消" /></el-select></el-form-item>
|
||
</template>
|
||
<template v-if="dialogType === 'mini'">
|
||
<el-form-item label="产品类型"><el-input v-model="form.product_type" /></el-form-item>
|
||
<el-form-item label="金额"><el-input v-model="form.amount" /></el-form-item>
|
||
<el-form-item label="跟进内容"><el-input v-model="form.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
|
||
<el-form-item label="状态"><el-select v-model="form.status"><el-option label="跟进中" value="跟进中" /><el-option label="已签约" value="已签约" /><el-option label="已流失" value="已流失" /></el-select></el-form-item>
|
||
<el-form-item label="预计列收时间"><el-date-picker v-model="form.expected_revenue_date" type="month" placeholder="选择月份" style="width:100%" value-format="YYYY-MM" /></el-form-item>
|
||
</template>
|
||
<template v-if="dialogType === 'key'">
|
||
<el-form-item label="紧急重要度">
|
||
<div class="method-grid">
|
||
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="form.urgency_level===u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="form.urgency_level=u">{{ u }}</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="内容描述"><el-input v-model="form.description" type="textarea" :rows="4" /></el-form-item>
|
||
<el-form-item label="进展状态"><el-select v-model="form.progress_status"><el-option label="未开始" value="未开始" /><el-option label="进行中" value="进行中" /><el-option label="已完成" value="已完成" /></el-select></el-form-item>
|
||
<el-form-item label="计划拜访时间"><el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" style="width:100%" value-format="YYYY-MM-DD" /></el-form-item>
|
||
<el-form-item label="计划拜访人(可多选)">
|
||
<el-select v-model="plannedVisitors" multiple filterable allow-create default-first-option placeholder="选择或输入姓名(可多选)" style="width:100%" @change="onVisitorsChange">
|
||
<el-option v-for="u in allUsers" :key="u.id" :label="u.name + (u.role==='director'?' (支局长)':u.role==='leader'?' (分管领导)':'')" :value="u.name" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="拜访对象"><el-input v-model="form.visit_target" /></el-form-item>
|
||
</template>
|
||
</el-form>
|
||
<EditLogPanel v-if="dialogMode === 'edit'" :edit-log="form.edit_log || []" />
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<ImagePreview v-model="previewDialogVisible" :image-url="previewImageUrl" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-head { margin-bottom: 20px; }
|
||
.page-head-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; }
|
||
.page-head-controls { display: flex; align-items: center; gap: 8px; }
|
||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; white-space: nowrap; }
|
||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||
.date-group { margin-bottom: 20px; }
|
||
.date-title { display: flex; align-items: center; margin: 12px 0 8px; font-family: var(--font-heading); font-size: 14px; color: var(--ink); letter-spacing: 0.04em; }
|
||
.empty { text-align: center; color: var(--c-text-muted); padding: 40px 0; font-family: var(--font-body); }
|
||
.quick-create-btn { display: inline-flex; align-items: center; gap: 4px; background: none; border: 1px dashed var(--gold); padding: 6px 12px; color: var(--gold-dark); font-family: var(--font-body); font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
||
.quick-create-btn:hover { border-color: var(--vermilion); color: var(--vermilion); background: rgba(184,71,46,0.03); }
|
||
.select-empty-create { padding: 8px 12px; text-align: center; }
|
||
.required-star { color: var(--vermilion); font-weight: 700; }
|
||
</style>
|