Files
qiji/frontend/src/views/desktop/ManagerWorkspace.vue
T
v6ole ab20bc5a1f Merge branch 'main' into develop
# 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
2026-08-17 09:31:12 +08:00

689 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>