dcb0b826de
WorkPlans/MiniBusiness/KeyVisits/ManagerWorkspace 四个页面的 openEdit 均直接 spread item 到 form,未确保客户在 el-select 选项列表中。修复:编辑前检查并补充客户到 customers 列表。 Co-Authored-By: Claude <noreply@anthropic.com>
333 lines
16 KiB
Vue
333 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useThemeStore } from '@/stores/theme'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { todayStr } from '@/utils'
|
||
import { getMgrStyle, loadManagerColors } from '@/utils/managerColor'
|
||
import { useScreenshot } from '@/utils/screenshot'
|
||
import api from '@/api/index'
|
||
import EditLogPanel from '@/components/EditLogPanel.vue'
|
||
|
||
const auth = useAuthStore()
|
||
const themeStore = useThemeStore()
|
||
const loading = ref(false)
|
||
const isLight = computed(() => themeStore.currentTheme === 'light')
|
||
const keyVisits = ref<any[]>([])
|
||
const customers = ref<any[]>([])
|
||
const shotRef = ref<HTMLElement | null>(null)
|
||
const { capturing, captureEl } = useScreenshot()
|
||
const allUsers = ref<any[]>([])
|
||
const plannedVisitors = ref<string[]>([])
|
||
|
||
const dialogVisible = ref(false)
|
||
const dialogMode = ref<'create' | 'edit'>('create')
|
||
const form = ref<any>({})
|
||
const statusPick = ref<Record<string, string>>({})
|
||
const keyStatuses = ['未开始', '进行中', '已完成']
|
||
|
||
// ── Filters ──
|
||
const filterManager = ref('')
|
||
const filterStatus = ref('')
|
||
|
||
const managerOptions = computed(() => {
|
||
const seen = new Set<string>()
|
||
return keyVisits.value
|
||
.map((k: any) => k.manager_name || '未知')
|
||
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
|
||
.sort()
|
||
})
|
||
|
||
const filteredItems = computed(() => {
|
||
let list = keyVisits.value
|
||
if (filterManager.value) list = list.filter((k: any) => (k.manager_name || '未知') === filterManager.value)
|
||
if (filterStatus.value) list = list.filter((k: any) => k.progress_status === filterStatus.value)
|
||
return list
|
||
})
|
||
|
||
// ── Three-level sort: urgency → planned_date → manager pinyin ──
|
||
const urgencyWeight: Record<string, number> = { '紧急': 0, '重要': 1, '普通': 2 }
|
||
|
||
const sortedItems = computed(() => {
|
||
return [...filteredItems.value].sort((a: any, b: any) => {
|
||
const ua = urgencyWeight[a.urgency_level] ?? 3
|
||
const ub = urgencyWeight[b.urgency_level] ?? 3
|
||
if (ua !== ub) return ua - ub
|
||
if (a.planned_date !== b.planned_date) return (a.planned_date || '').localeCompare(b.planned_date || '')
|
||
return (a.manager_name || '').localeCompare(b.manager_name || '', 'zh-CN')
|
||
})
|
||
})
|
||
|
||
function resetFilters() {
|
||
filterManager.value = ''
|
||
filterStatus.value = ''
|
||
}
|
||
|
||
async function handleScreenshot() {
|
||
const d = new Date().toISOString().slice(0, 10)
|
||
await captureEl(shotRef.value, `要客拜访_${d}.png`)
|
||
}
|
||
|
||
const managerSummary = computed(() => {
|
||
const map: Record<string, number> = {}
|
||
keyVisits.value.forEach((k: any) => {
|
||
const n = k.manager_name || '未知'
|
||
map[n] = (map[n] || 0) + 1
|
||
})
|
||
return Object.entries(map).sort((a, b) => b[1] - a[1])
|
||
})
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([loadItems(), loadCustomers(), loadUsers(), loadManagerColors()])
|
||
})
|
||
|
||
async function loadUsers() {
|
||
try {
|
||
const res = await api.get('/users/')
|
||
allUsers.value = res.data || []
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function loadCustomers(q?: string) {
|
||
try {
|
||
const params: any = { page_size: 500 }
|
||
if (q) params.search = q
|
||
const res = await api.get('/customers/', { params })
|
||
customers.value = res.data.items || []
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function loadItems() {
|
||
loading.value = true
|
||
try {
|
||
const res = await api.get('/key-visits/')
|
||
keyVisits.value = res.data
|
||
} catch (e: any) { ElMessage.error('加载失败') }
|
||
finally { loading.value = false }
|
||
}
|
||
|
||
function openCreate() {
|
||
dialogMode.value = 'create'
|
||
form.value = { customer_id: '', urgency_level: '一般', description: '', progress_status: '未开始', planned_date: '', planned_visitor: '', visit_target: '' }
|
||
plannedVisitors.value = []
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function openEdit(item: any) {
|
||
dialogMode.value = 'edit'
|
||
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 })
|
||
}
|
||
plannedVisitors.value = item.planned_visitor ? item.planned_visitor.split('、') : []
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
function onVisitorsChange(val: string[]) {
|
||
form.value.planned_visitor = val.join('、')
|
||
}
|
||
|
||
async function handleSave() {
|
||
try {
|
||
if (dialogMode.value === 'create') {
|
||
await api.post('/key-visits/', form.value)
|
||
} else {
|
||
await api.put(`/key-visits/${form.value.id}`, form.value)
|
||
}
|
||
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
|
||
dialogVisible.value = false
|
||
await loadItems()
|
||
} catch (e: any) { ElMessage.error('保存失败: ' + (e.response?.data?.detail || e.message)) }
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
try {
|
||
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
|
||
await api.delete(`/key-visits/${id}`)
|
||
ElMessage.success('已删除')
|
||
await loadItems()
|
||
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
|
||
}
|
||
|
||
async function quickStatusChange(row: any, newStatus: string) {
|
||
try {
|
||
await ElMessageBox.confirm(`确定将进展改为「${newStatus}」?`, '确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
|
||
await api.put(`/key-visits/${row.id}`, { progress_status: newStatus })
|
||
ElMessage.success('状态已更新')
|
||
await loadItems()
|
||
} catch (e: any) {
|
||
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
|
||
delete statusPick.value[row.id]
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="key-visits-page" v-loading="loading">
|
||
<div ref="shotRef">
|
||
<div class="page-head">
|
||
<div class="page-head-row">
|
||
<div>
|
||
<h2 class="page-title">要客拜访</h2>
|
||
<p class="page-desc">重要客户拜访计划,按紧急程度排序跟踪。</p>
|
||
</div>
|
||
<div class="page-head-actions">
|
||
<el-button type="primary" @click="openCreate" class="screenshot-hide">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
新建要客
|
||
</el-button>
|
||
<el-button type="warning" :loading="capturing" @click="handleScreenshot" class="screenshot-hide">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
|
||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||
<polyline points="21 15 16 10 5 21"></polyline>
|
||
</svg>
|
||
截图导出
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
<div class="page-rule"></div>
|
||
<div v-if="managerSummary.length" class="summary-bar">
|
||
<span class="summary-label">客户经理汇总</span>
|
||
<span v-for="[name, count] in managerSummary" :key="name" class="summary-chip" :class="{ 'summary-chip--active': filterManager === name }" :style="getMgrStyle(name, isLight)" @click="filterManager = filterManager === name ? '' : name">{{ name }} · {{ count }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filter Bar -->
|
||
<div class="filter-bar">
|
||
<div class="filter-row">
|
||
<div class="filter-item">
|
||
<label class="filter-label">客户经理</label>
|
||
<el-select v-model="filterManager" clearable placeholder="全部" size="small" style="width:150px">
|
||
<el-option v-for="m in managerOptions" :key="m" :label="m" :value="m" />
|
||
</el-select>
|
||
</div>
|
||
<div class="filter-item">
|
||
<label class="filter-label">进展</label>
|
||
<el-select v-model="filterStatus" clearable placeholder="全部" size="small" style="width:120px">
|
||
<el-option v-for="s in keyStatuses" :key="s" :label="s" :value="s" />
|
||
</el-select>
|
||
</div>
|
||
<el-button v-if="filterManager || filterStatus" size="small" plain @click="resetFilters">重置</el-button>
|
||
<span class="filter-count">{{ filteredItems.length }} / {{ keyVisits.length }} 条</span>
|
||
</div>
|
||
</div>
|
||
|
||
<el-card>
|
||
<el-table :data="sortedItems" stripe size="small" v-if="sortedItems.length" v-column-resize>
|
||
<el-table-column type="index" label="序号" width="50" />
|
||
<el-table-column prop="customer_name" label="客户" width="160">
|
||
<template #default="{ row }">
|
||
<el-link type="primary" :underline="false" @click="openEdit(row)">{{ row.customer_name }}</el-link>
|
||
<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>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="客户经理" width="100">
|
||
<template #default="{ row }">
|
||
<span class="mgr-tag" :style="getMgrStyle(row.manager_name, isLight)">{{ row.manager_name || '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="urgency_level" label="重要度" width="90">
|
||
<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="250" show-overflow-tooltip />
|
||
<el-table-column label="进展" width="130">
|
||
<template #default="{ row }">
|
||
<el-select
|
||
v-model="statusPick[row.id]"
|
||
size="small"
|
||
style="width:100%"
|
||
:placeholder="row.progress_status"
|
||
@change="(v: string) => quickStatusChange(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="110" />
|
||
<el-table-column label="操作" width="80" fixed="right" class-name="screenshot-hide">
|
||
<template #default="{ row }">
|
||
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div v-if="!keyVisits.length" class="empty">暂无需重点关注的客户</div>
|
||
<div v-else-if="keyVisits.length && !filteredItems.length" class="empty">无匹配结果</div>
|
||
</el-card>
|
||
</div><!-- /shotRef -->
|
||
|
||
<!-- Dialog -->
|
||
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 要客拜访'" width="520px">
|
||
<el-form label-position="top">
|
||
<el-form-item label="客户单位">
|
||
<el-select v-model="form.customer_id" filterable remote :remote-method="(q: string) => loadCustomers(q)" placeholder="搜索选择客户" style="width:100%">
|
||
<el-option v-for="c in customers" :key="c.id" :label="c.name" :value="c.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<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" placeholder="请输入要客拜访描述" /></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" placeholder="对方联系人/部门" /></el-form-item>
|
||
</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>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.edit-indicator { font-size: 12px; margin-left: 3px; opacity: 0.5; cursor: help; }
|
||
.page-head { margin-bottom: 20px; }
|
||
.page-head-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||
.page-head-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
||
.page-title { margin: 0; font-family: var(--font-heading); font-size: 22px; font-weight: 400; color: var(--ink); letter-spacing: 0.06em; }
|
||
.page-desc { margin: 4px 0 0; font-size: 13px; color: var(--c-text-muted); font-family: var(--font-body); }
|
||
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 12px; }
|
||
.summary-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding: 10px 14px; background: var(--c-bg-light, #faf9f6); border-radius: 6px; border: 1px solid var(--c-border, #e8e5df); }
|
||
.summary-label { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-body); margin-right: 4px; }
|
||
.summary-chip { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; cursor: pointer; transition: opacity 0.2s, transform 0.15s; }
|
||
.summary-chip:hover { opacity: 0.8; transform: translateY(-1px); }
|
||
.summary-chip--active { outline: 2px solid var(--gold); outline-offset: 1px; }
|
||
.mgr-tag { display: inline-block; padding: 1px 9px; border-radius: 10px; font-size: 12px; color: var(--mgr-chip-text); white-space: nowrap; }
|
||
.method-grid { display: flex; gap: 8px; }
|
||
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
|
||
.filter-bar { margin-bottom: 16px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--warm-border); }
|
||
.filter-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||
.filter-label { font-size: 13px; color: var(--warm-gray); font-family: var(--font-body); white-space: nowrap; }
|
||
.filter-count { font-size: 12px; color: var(--c-text-muted); font-family: var(--font-mono); margin-left: auto; }
|
||
</style>
|