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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-11 22:17:45 +08:00

286 lines
13 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, 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 workPlans = ref<any[]>([])
const customers = ref<any[]>([])
const shotRef = ref<HTMLElement | null>(null)
const { capturing, captureEl } = useScreenshot()
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const form = ref<any>({})
const statusPick = ref<Record<string, string>>({})
const planStatuses = ['计划中', '已完成', '已取消']
// ── Filters ──
const filterManager = ref('')
const filterStatus = ref('')
const isLight = computed(() => themeStore.currentTheme === 'light')
const managerOptions = computed(() => {
const seen = new Set<string>()
return workPlans.value
.map((w: any) => w.manager_name || '未知')
.filter((n: string) => { if (seen.has(n)) return false; seen.add(n); return true })
.sort()
})
const filteredPlans = computed(() => {
let list = workPlans.value
if (filterManager.value) list = list.filter((w: any) => (w.manager_name || '未知') === filterManager.value)
if (filterStatus.value) list = list.filter((w: any) => w.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> = {}
workPlans.value.forEach((w: any) => {
const n = w.manager_name || '未知'
map[n] = (map[n] || 0) + 1
})
return Object.entries(map).sort((a, b) => b[1] - a[1])
})
onMounted(async () => {
await Promise.all([loadPlans(), loadCustomers(), loadManagerColors()])
})
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 loadPlans() {
loading.value = true
try {
const res = await api.get('/work-plans/')
workPlans.value = res.data
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function openCreate() {
dialogMode.value = 'create'
form.value = { customer_id: '', plan_content: '', plan_date: todayStr(), status: '计划中' }
dialogVisible.value = true
}
function openEdit(item: any) {
dialogMode.value = 'edit'
form.value = { ...item }
dialogVisible.value = true
}
async function handleSave() {
try {
if (dialogMode.value === 'create') {
await api.post('/work-plans/', form.value)
} else {
await api.put(`/work-plans/${form.value.id}`, form.value)
}
ElMessage.success(dialogMode.value === 'create' ? '已创建' : '已更新')
dialogVisible.value = false
await loadPlans()
} 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(`/work-plans/${id}`)
ElMessage.success('已删除')
await loadPlans()
} 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(`/work-plans/${row.id}`, { status: newStatus })
ElMessage.success('状态已更新')
await loadPlans()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error('更新失败: ' + (e.response?.data?.detail || e.message))
delete statusPick.value[row.id]
}
}
</script>
<template>
<div class="work-plans-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 planStatuses" :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">{{ filteredPlans.length }} / {{ workPlans.length }} </span>
</div>
</div>
<el-card>
<el-table :data="filteredPlans" stripe size="small" v-if="filteredPlans.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="plan_content" label="工作计划" min-width="280" show-overflow-tooltip />
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column label="状态" width="130">
<template #default="{ row }">
<el-select
v-model="statusPick[row.id]"
size="small"
style="width:100%"
:placeholder="row.status"
@change="(v: string) => quickStatusChange(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="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="!workPlans.length" class="empty">暂无工作计划</div>
<div v-else-if="workPlans.length && !filteredPlans.length" class="empty">无匹配结果</div>
</el-card>
</div><!-- /shotRef -->
<!-- Dialog -->
<el-dialog v-model="dialogVisible" :title="(dialogMode === 'create' ? '新建' : '编辑') + ' 工作计划'" width="500px">
<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="计划拜访时间">
<el-date-picker v-model="form.plan_date" type="date" style="width:100%" />
</el-form-item>
<el-form-item label="工作计划">
<el-input v-model="form.plan_content" type="textarea" :rows="4" placeholder="请输入计划内容" />
</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>
<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; }
.empty { text-align: center; color: var(--c-text-muted); padding: 48px 0; font-family: var(--font-body); }
/* ── Filter Bar ── */
.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>