feat: 列宽拖拽 + 筛选栏 + 截图导出 + UI优化

- 新增 v-column-resize 指令,11 张表格均支持拖拽调整列宽
- 工作计划/商机跟单/要客拜访新增筛选栏(客户经理 + 状态)
- 汇总栏 chip 可点击筛选,支持重置与计数
- 新增截图导出功能(dom-to-image-more,像素级文字渲染)
- 截图范围从标题开始,自动隐藏操作按钮,5px白边
- 截图时页面闪烁动画反馈
- 全局 CSS 完善(列宽手柄、截图模式、捕获动画)
- 仪表盘 UI 细节优化 + 亮灯表标签调整
- 登录页 UI 改进

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-11 22:17:45 +08:00
parent deeafe239f
commit e8b92a6f99
17 changed files with 767 additions and 156 deletions
+84 -11
View File
@@ -5,6 +5,7 @@ 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'
@@ -14,6 +15,8 @@ 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[]>([])
@@ -23,6 +26,35 @@ 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
})
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) => {
@@ -116,28 +148,59 @@ async function quickStatusChange(row: any, newStatus: string) {
<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>
<el-button type="primary" @click="openCreate">
<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>
<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" :style="getMgrStyle(name, isLight)">{{ name }} · {{ count }}</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="keyVisits" stripe size="small" v-if="keyVisits.length">
<el-table :data="filteredItems" stripe size="small" v-if="filteredItems.length" v-column-resize>
<el-table-column type="index" label="序号" width="50" />
<el-table-column prop="customer_name" label="客户" width="160">
<template #default="{ row }">
@@ -176,14 +239,16 @@ async function quickStatusChange(row: any, newStatus: string) {
<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">
<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-else class="empty">暂无需重点关注的客户</div>
<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">
@@ -231,13 +296,21 @@ async function quickStatusChange(row: any, newStatus: string) {
.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; }
.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>